Просмотр исходного кода

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 7 месяцев назад
Родитель
Сommit
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
 ## [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
 ### Enhancements
 - **TOTP Authenticator Support for Bambu Cloud** (Issue #182):
 - **TOTP Authenticator Support for Bambu Cloud** (Issue #182):
   - Added support for TOTP-based two-factor authentication when connecting to Bambu Cloud
   - 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)
 - Group-based permissions (50+ granular permissions)
 - Default groups: Administrators, Operators, Viewers
 - Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
 - JWT tokens with secure password hashing
+- Comprehensive API protection (200+ endpoints secured)
 - User management (create, edit, delete, groups)
 - User management (create, edit, delete, groups)
 - User activity tracking (who uploaded archives, library files, queued prints, started prints)
 - 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 import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_history import AMSSensorHistory
+from backend.app.models.user import User
 
 
 router = APIRouter(prefix="/ams-history", tags=["ams-history"])
 router = APIRouter(prefix="/ams-history", tags=["ams-history"])
 
 
@@ -38,6 +41,7 @@ async def get_ams_history(
     ams_id: int,
     ams_id: int,
     hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
     hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
 ):
     """Get AMS sensor history for a specific printer and AMS unit."""
     """Get AMS sensor history for a specific printer and AMS unit."""
     since = datetime.now() - timedelta(hours=hours)
     since = datetime.now() - timedelta(hours=hours)
@@ -101,6 +105,7 @@ async def delete_old_history(
     printer_id: int,
     printer_id: int,
     days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
     days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
 ):
     """Delete old AMS history data for a printer."""
     """Delete old AMS history data for a printer."""
     cutoff = datetime.now() - timedelta(days=days)
     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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
+from backend.app.models.user import User
 from backend.app.schemas.api_key import (
 from backend.app.schemas.api_key import (
     APIKeyCreate,
     APIKeyCreate,
     APIKeyCreateResponse,
     APIKeyCreateResponse,
@@ -20,7 +22,10 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
 
 
 
 
 @router.get("/", response_model=list[APIKeyResponse])
 @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)."""
     """List all API keys (without full key values)."""
     result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
     result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
     return list(result.scalars().all())
     return list(result.scalars().all())
@@ -30,6 +35,7 @@ async def list_api_keys(db: AsyncSession = Depends(get_db)):
 async def create_api_key(
 async def create_api_key(
     data: APIKeyCreate,
     data: APIKeyCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
 ):
 ):
     """Create a new API key.
     """Create a new API key.
 
 
@@ -74,6 +80,7 @@ async def create_api_key(
 async def get_api_key(
 async def get_api_key(
     key_id: int,
     key_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
 ):
 ):
     """Get an API key by ID."""
     """Get an API key by ID."""
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))
@@ -90,6 +97,7 @@ async def update_api_key(
     key_id: int,
     key_id: int,
     data: APIKeyUpdate,
     data: APIKeyUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
 ):
 ):
     """Update an API key."""
     """Update an API key."""
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))
     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(
 async def delete_api_key(
     key_id: int,
     key_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
 ):
 ):
     """Delete (revoke) an API key."""
     """Delete (revoke) an API key."""
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))
     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 import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
@@ -118,6 +121,7 @@ async def list_archives(
     limit: int = 50,
     limit: int = 50,
     offset: int = 0,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """List archived prints."""
     """List archived prints."""
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -148,6 +152,7 @@ async def search_archives(
     limit: int = 50,
     limit: int = 50,
     offset: int = 0,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Full-text search across archives.
     """Full-text search across archives.
 
 
@@ -229,7 +234,10 @@ async def search_archives(
 
 
 
 
 @router.post("/search/rebuild-index")
 @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.
     """Rebuild the full-text search index from existing archives.
 
 
     Use this if search results seem incomplete or incorrect.
     Use this if search results seem incomplete or incorrect.
@@ -267,6 +275,7 @@ async def analyze_failures(
     printer_id: int | None = None,
     printer_id: int | None = None,
     project_id: int | None = None,
     project_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Analyze failure patterns across prints.
     """Analyze failure patterns across prints.
 
 
@@ -291,6 +300,7 @@ async def analyze_failures(
 async def compare_archives(
 async def compare_archives(
     archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
     archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Compare multiple archives side by side.
     """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)"),
     date_to: str | None = Query(None, description="End date (ISO format)"),
     search: str | None = None,
     search: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Export archives to CSV or Excel format.
     """Export archives to CSV or Excel format.
 
 
@@ -393,6 +404,7 @@ async def export_stats(
     printer_id: int | None = None,
     printer_id: int | None = None,
     project_id: int | None = None,
     project_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
 ):
     """Export statistics summary to CSV or Excel format."""
     """Export statistics summary to CSV or Excel format."""
     from fastapi.responses import StreamingResponse
     from fastapi.responses import StreamingResponse
@@ -421,7 +433,10 @@ async def export_stats(
 
 
 
 
 @router.get("/stats", response_model=ArchiveStats)
 @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."""
     """Get statistics across all archives."""
     # Total counts
     # Total counts
     total_result = await db.execute(select(func.count(PrintArchive.id)))
     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")
 @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.
     """List all unique tags with usage counts.
 
 
     Returns a list of tags sorted by count (descending), then by name.
     Returns a list of tags sorted by count (descending), then by name.
@@ -604,6 +622,7 @@ async def rename_tag(
     tag_name: str,
     tag_name: str,
     request: Request,
     request: Request,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
 ):
     """Rename a tag across all archives.
     """Rename a tag across all archives.
 
 
@@ -646,7 +665,11 @@ async def rename_tag(
 
 
 
 
 @router.delete("/tags/{tag_name}")
 @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.
     """Delete a tag from all archives.
 
 
     Returns the count of affected 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)
 @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."""
     """Get a specific archive."""
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
@@ -694,6 +721,7 @@ async def find_similar_archives(
     archive_id: int,
     archive_id: int,
     limit: int = 10,
     limit: int = 10,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Find archives with similar settings for comparison.
     """Find archives with similar settings for comparison.
 
 
@@ -762,6 +790,7 @@ async def update_archive(
 async def toggle_favorite(
 async def toggle_favorite(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
 ):
     """Toggle favorite status for an archive."""
     """Toggle favorite status for an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     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)
 @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."""
     """Rescan the 3MF file and update metadata."""
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.archive import ThreeMFParser
     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")
 @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."""
     """Recalculate costs for all archives based on filament usage and prices."""
     from backend.app.api.routes.settings import get_setting
     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")
 @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."""
     """Rescan all archives and update their metadata."""
     from backend.app.services.archive import ThreeMFParser
     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")
 @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."""
     """Get duplicates for a specific archive."""
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     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")
 @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."""
     """Compute and store content hashes for all archives missing them."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))
     result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))
     archives = list(result.scalars().all())
     archives = list(result.scalars().all())
@@ -991,6 +1037,7 @@ async def download_archive(
     archive_id: int,
     archive_id: int,
     inline: bool = False,
     inline: bool = False,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Download the 3MF file."""
     """Download the 3MF file."""
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -1018,6 +1065,7 @@ async def download_archive_with_filename(
     archive_id: int,
     archive_id: int,
     filename: str,
     filename: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
     """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -1037,8 +1085,14 @@ async def download_archive_with_filename(
 
 
 
 
 @router.get("/{archive_id}/thumbnail")
 @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)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
     if not archive or not archive.thumbnail_path:
     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")
 @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)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
     if not archive or not archive.timelapse_path:
     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(
 async def scan_timelapse(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
 ):
     """Scan printer for timelapse matching this archive and attach it."""
     """Scan printer for timelapse matching this archive and attach it."""
     from backend.app.models.printer import Printer
     from backend.app.models.printer import Printer
@@ -1317,6 +1378,7 @@ async def select_timelapse(
     archive_id: int,
     archive_id: int,
     filename: str = Query(..., description="Timelapse filename to attach"),
     filename: str = Query(..., description="Timelapse filename to attach"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
 ):
     """Manually select a timelapse from the printer to attach."""
     """Manually select a timelapse from the printer to attach."""
     from backend.app.models.printer import Printer
     from backend.app.models.printer import Printer
@@ -1401,6 +1463,7 @@ async def upload_timelapse(
     archive_id: int,
     archive_id: int,
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
 ):
     """Manually upload a timelapse video to an archive."""
     """Manually upload a timelapse video to an archive."""
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -1421,7 +1484,11 @@ async def upload_timelapse(
 
 
 
 
 @router.get("/{archive_id}/timelapse/info")
 @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."""
     """Get timelapse video metadata for editor."""
     from backend.app.schemas.timelapse import TimelapseInfoResponse
     from backend.app.schemas.timelapse import TimelapseInfoResponse
     from backend.app.services.timelapse_processor import TimelapseProcessor
     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),
     count: int = Query(10, ge=1, le=30),
     width: int = Query(160, ge=80, le=320),
     width: int = Query(160, ge=80, le=320),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Generate timeline thumbnail frames for visual scrubbing."""
     """Generate timeline thumbnail frames for visual scrubbing."""
     import base64
     import base64
@@ -1489,6 +1557,7 @@ async def process_timelapse(
     output_filename: str = Form(None),
     output_filename: str = Form(None),
     audio: UploadFile = File(None),
     audio: UploadFile = File(None),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
 ):
     """Process timelapse with trim, speed, and optional audio overlay."""
     """Process timelapse with trim, speed, and optional audio overlay."""
     import shutil
     import shutil
@@ -1592,6 +1661,7 @@ async def upload_photo(
     archive_id: int,
     archive_id: int,
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
 ):
     """Upload a photo of the printed result."""
     """Upload a photo of the printed result."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1636,7 +1706,10 @@ async def get_photo(
     filename: str,
     filename: str,
     db: AsyncSession = Depends(get_db),
     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))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
     if not archive:
     if not archive:
@@ -1666,6 +1739,7 @@ async def delete_photo(
     archive_id: int,
     archive_id: int,
     filename: str,
     filename: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
 ):
 ):
     """Delete a photo."""
     """Delete a photo."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1703,7 +1777,10 @@ async def get_qrcode(
     size: int = 200,
     size: int = 200,
     db: AsyncSession = Depends(get_db),
     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:
     try:
         import qrcode
         import qrcode
         from PIL import Image as PILImage
         from PIL import Image as PILImage
@@ -1751,7 +1828,11 @@ async def get_qrcode(
 
 
 
 
 @router.get("/{archive_id}/capabilities")
 @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."""
     """Check what viewing capabilities are available for this 3MF file."""
     import json
     import json
     import xml.etree.ElementTree as ET
     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")
 @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."""
     """Extract and return G-code from the 3MF file."""
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     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")
 @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.
     """Get the plate preview image from the 3MF file.
 
 
     Returns the slicer-generated plate thumbnail which shows the model
     Returns the slicer-generated plate thumbnail which shows the model
     with correct colors and positioning.
     with correct colors and positioning.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
     """
     """
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
@@ -2068,7 +2158,7 @@ async def upload_archive(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     printer_id: int | None = None,
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     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."""
     """Manually upload a 3MF file to archive."""
     if not file.filename or not file.filename.endswith(".3mf"):
     if not file.filename or not file.filename.endswith(".3mf"):
@@ -2103,7 +2193,7 @@ async def upload_archives_bulk(
     files: list[UploadFile] = File(...),
     files: list[UploadFile] = File(...),
     printer_id: int | None = None,
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     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."""
     """Bulk upload multiple 3MF files to archive."""
     results = []
     results = []
@@ -2157,6 +2247,7 @@ async def upload_archives_bulk(
 async def get_archive_plates(
 async def get_archive_plates(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Get available plates from a multi-plate 3MF archive.
     """Get available plates from a multi-plate 3MF archive.
 
 
@@ -2337,7 +2428,10 @@ async def get_plate_thumbnail(
     plate_index: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
     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)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
     if not archive:
     if not archive:
@@ -2364,6 +2458,7 @@ async def get_filament_requirements(
     archive_id: int,
     archive_id: int,
     plate_id: int | None = None,
     plate_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Get filament requirements from the archived 3MF file.
     """Get filament requirements from the archived 3MF file.
 
 
@@ -2659,7 +2754,11 @@ async def reprint_archive(
 
 
 
 
 @router.get("/{archive_id}/project-page")
 @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."""
     """Get the project page data from the 3MF file."""
     from backend.app.schemas.archive import ProjectPageResponse
     from backend.app.schemas.archive import ProjectPageResponse
     from backend.app.services.archive import ProjectPageParser
     from backend.app.services.archive import ProjectPageParser
@@ -2684,6 +2783,7 @@ async def update_project_page(
     archive_id: int,
     archive_id: int,
     update_data: dict,
     update_data: dict,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
 ):
     """Update project page metadata in the 3MF file."""
     """Update project page metadata in the 3MF file."""
     from backend.app.services.archive import ProjectPageParser
     from backend.app.services.archive import ProjectPageParser
@@ -2714,7 +2814,10 @@ async def get_project_image(
     image_path: str,
     image_path: str,
     db: AsyncSession = Depends(get_db),
     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
     from backend.app.services.archive import ProjectPageParser
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -2750,6 +2853,7 @@ async def upload_source_3mf(
     archive_id: int,
     archive_id: int,
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
 ):
     """Upload the original source 3MF project file for an archive."""
     """Upload the original source 3MF project file for an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     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(
 async def download_source_3mf(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Download the source 3MF project file."""
     """Download the source 3MF project file."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     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,
     archive_id: int,
     filename: str,
     filename: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
     """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     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(...),
     file: UploadFile = File(...),
     print_name: str = Query(None, description="Match archive by print name"),
     print_name: str = Query(None, description="Match archive by print name"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
 ):
     """Upload source 3MF and match to archive by print name.
     """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(
 async def delete_source_3mf(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
 ):
 ):
     """Delete the source 3MF project file from an archive."""
     """Delete the source 3MF project file from an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2969,6 +3077,7 @@ async def upload_f3d(
     archive_id: int,
     archive_id: int,
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
 ):
     """Upload a Fusion 360 design file for an archive."""
     """Upload a Fusion 360 design file for an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3015,6 +3124,7 @@ async def upload_f3d(
 async def download_f3d(
 async def download_f3d(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Download the Fusion 360 design file."""
     """Download the Fusion 360 design file."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3043,6 +3153,7 @@ async def download_f3d(
 async def delete_f3d(
 async def delete_f3d(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
 ):
 ):
     """Delete the Fusion 360 design file from an archive."""
     """Delete the Fusion 360 design file from an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.services.camera import (
 from backend.app.services.camera import (
     capture_camera_frame,
     capture_camera_frame,
     generate_chamber_image_stream,
     generate_chamber_image_stream,
@@ -353,6 +356,8 @@ async def camera_stream(
     This endpoint returns a multipart MJPEG stream that can be used directly
     This endpoint returns a multipart MJPEG stream that can be used directly
     in an <img> tag or video player.
     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:
     Uses external camera if configured, otherwise uses built-in camera:
     - External: MJPEG, RTSP, or HTTP snapshot
     - External: MJPEG, RTSP, or HTTP snapshot
     - A1/P1: Chamber image protocol (port 6000)
     - 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"])
 @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.
     """Stop all active camera streams for a printer.
 
 
     This can be called by the frontend when the camera window is closed.
     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.
     """Capture a single frame from the printer camera.
 
 
     Returns a JPEG image.
     Returns a JPEG image.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
     """
     """
     import tempfile
     import tempfile
     from pathlib import Path
     from pathlib import Path
@@ -574,6 +584,7 @@ async def camera_snapshot(
 async def test_camera(
 async def test_camera(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Test camera connection for a printer.
     """Test camera connection for a printer.
 
 
@@ -591,7 +602,10 @@ async def test_camera(
 
 
 
 
 @router.get("/{printer_id}/camera/status")
 @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.
     """Get the status of an active camera stream.
 
 
     Returns whether a stream is active and when the last frame was received.
     Returns whether a stream is active and when the last frame was received.
@@ -658,6 +672,7 @@ async def test_external_camera(
     url: str,
     url: str,
     camera_type: str,
     camera_type: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Test external camera connection.
     """Test external camera connection.
 
 
@@ -684,6 +699,7 @@ async def check_plate_empty(
     use_external: bool = False,
     use_external: bool = False,
     include_debug_image: bool = False,
     include_debug_image: bool = False,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Check if the build plate is empty using camera vision.
     """Check if the build plate is empty using camera vision.
 
 
@@ -791,6 +807,7 @@ async def calibrate_plate_detection(
     label: str | None = None,
     label: str | None = None,
     use_external: bool = False,
     use_external: bool = False,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Calibrate plate detection by capturing a reference image of the empty plate.
     """Calibrate plate detection by capturing a reference image of the empty plate.
 
 
@@ -854,6 +871,7 @@ async def delete_plate_calibration(
     printer_id: int,
     printer_id: int,
     plate_type: str | None = None,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Delete the plate detection calibration for a printer and plate type.
     """Delete the plate detection calibration for a printer and plate type.
 
 
@@ -894,6 +912,7 @@ async def get_plate_detection_status(
     printer_id: int,
     printer_id: int,
     plate_type: str | None = None,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Check plate detection status for a printer and plate type.
     """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(
 async def get_plate_references(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Get all calibration references for a printer with metadata.
     """Get all calibration references for a printer with metadata.
 
 
@@ -971,7 +991,10 @@ async def get_reference_thumbnail(
     index: int,
     index: int,
     db: AsyncSession = Depends(get_db),
     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 fastapi.responses import Response
 
 
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -997,6 +1020,7 @@ async def update_reference_label(
     index: int,
     index: int,
     label: str,
     label: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Update the label for a calibration reference."""
     """Update the label for a calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -1021,6 +1045,7 @@ async def delete_reference(
     printer_id: int,
     printer_id: int,
     index: int,
     index: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Delete a specific calibration reference."""
     """Delete a specific calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
     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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
+from backend.app.models.user import User
 from backend.app.schemas.cloud import (
 from backend.app.schemas.cloud import (
     CloudAuthStatus,
     CloudAuthStatus,
     CloudDevice,
     CloudDevice,
@@ -74,7 +77,10 @@ async def clear_token(db: AsyncSession) -> None:
 
 
 
 
 @router.get("/status", response_model=CloudAuthStatus)
 @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."""
     """Get current cloud authentication status."""
     token, email = await get_stored_token(db)
     token, email = await get_stored_token(db)
     cloud = get_cloud_service()
     cloud = get_cloud_service()
@@ -89,7 +95,11 @@ async def get_auth_status(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.post("/login", response_model=CloudLoginResponse)
 @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.
     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)
 @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).
     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)
 @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.
     Set access token directly.
 
 
@@ -182,7 +200,10 @@ async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_d
 
 
 
 
 @router.post("/logout")
 @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."""
     """Log out of Bambu Cloud."""
     cloud = get_cloud_service()
     cloud = get_cloud_service()
     cloud.logout()
     cloud.logout()
@@ -194,6 +215,7 @@ async def logout(db: AsyncSession = Depends(get_db)):
 async def get_slicer_settings(
 async def get_slicer_settings(
     version: str = "02.04.00.70",
     version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
 ):
     """
     """
     Get all slicer settings (filament, printer, process presets).
     Get all slicer settings (filament, printer, process presets).
@@ -250,7 +272,11 @@ async def get_slicer_settings(
 
 
 
 
 @router.get("/settings/{setting_id}")
 @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.
     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")
 @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.
     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])
 @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.
     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)
 @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.
     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")
 @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.
     Create a new slicer preset/setting.
 
 
@@ -539,6 +579,7 @@ async def update_setting(
     setting_id: str,
     setting_id: str,
     request: SlicerSettingUpdate,
     request: SlicerSettingUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """
     """
     Update an existing slicer preset/setting.
     Update an existing slicer preset/setting.
@@ -570,7 +611,11 @@ async def update_setting(
 
 
 
 
 @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
 @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.
     Delete a slicer preset/setting.
 
 
@@ -635,7 +680,10 @@ def _load_fields(preset_type: str) -> dict:
 
 
 
 
 @router.get("/fields/{preset_type}")
 @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.
     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")
 @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.
     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 fastapi import APIRouter
 from pydantic import BaseModel
 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 (
 from backend.app.services.discovery import (
     discovery_service,
     discovery_service,
     is_running_in_docker,
     is_running_in_docker,
@@ -60,7 +63,9 @@ class DiscoveredPrinterResponse(BaseModel):
 
 
 
 
 @router.get("/info", response_model=DiscoveryInfo)
 @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.)."""
     """Get discovery environment info (Docker detection, etc.)."""
     return DiscoveryInfo(
     return DiscoveryInfo(
         is_docker=is_running_in_docker(),
         is_docker=is_running_in_docker(),
@@ -70,13 +75,18 @@ async def get_discovery_info():
 
 
 
 
 @router.get("/status", response_model=DiscoveryStatus)
 @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."""
     """Get the current SSDP discovery status."""
     return DiscoveryStatus(running=discovery_service.is_running)
     return DiscoveryStatus(running=discovery_service.is_running)
 
 
 
 
 @router.post("/start", response_model=DiscoveryStatus)
 @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.
     """Start SSDP printer discovery.
 
 
     Args:
     Args:
@@ -87,14 +97,18 @@ async def start_discovery(duration: float = 10.0):
 
 
 
 
 @router.post("/stop", response_model=DiscoveryStatus)
 @router.post("/stop", response_model=DiscoveryStatus)
-async def stop_discovery():
+async def stop_discovery(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Stop SSDP printer discovery."""
     """Stop SSDP printer discovery."""
     await discovery_service.stop()
     await discovery_service.stop()
     return DiscoveryStatus(running=discovery_service.is_running)
     return DiscoveryStatus(running=discovery_service.is_running)
 
 
 
 
 @router.get("/printers", response_model=list[DiscoveredPrinterResponse])
 @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)."""
     """Get list of discovered printers (from both SSDP and subnet scan)."""
     # Combine results from both discovery methods
     # Combine results from both discovery methods
     printers = {}
     printers = {}
@@ -124,7 +138,10 @@ async def get_discovered_printers():
 
 
 
 
 @router.post("/scan", response_model=SubnetScanStatus)
 @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.
     """Start a subnet scan for Bambu printers.
 
 
     Use this when running in Docker where SSDP multicast doesn't work.
     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)
 @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."""
     """Get the current subnet scan status."""
     scanned, total = subnet_scanner.progress
     scanned, total = subnet_scanner.progress
     return SubnetScanStatus(
     return SubnetScanStatus(
@@ -158,7 +177,9 @@ async def get_scan_status():
 
 
 
 
 @router.post("/scan/stop", response_model=SubnetScanStatus)
 @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."""
     """Stop the current subnet scan."""
     subnet_scanner.stop()
     subnet_scanner.stop()
     scanned, total = subnet_scanner.progress
     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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.config import settings as app_settings
 from backend.app.core.database import get_db
 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.external_link import ExternalLink
+from backend.app.models.user import User
 from backend.app.schemas.external_link import (
 from backend.app.schemas.external_link import (
     ExternalLinkCreate,
     ExternalLinkCreate,
     ExternalLinkReorder,
     ExternalLinkReorder,
@@ -29,7 +32,10 @@ router = APIRouter(prefix="/external-links", tags=["external-links"])
 
 
 
 
 @router.get("/", response_model=list[ExternalLinkResponse])
 @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."""
     """List all external links ordered by sort_order."""
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     links = result.scalars().all()
     links = result.scalars().all()
@@ -40,6 +46,7 @@ async def list_external_links(db: AsyncSession = Depends(get_db)):
 async def create_external_link(
 async def create_external_link(
     link_data: ExternalLinkCreate,
     link_data: ExternalLinkCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
 ):
 ):
     """Create a new external link."""
     """Create a new external link."""
     # Get the highest sort_order to place new link at end
     # 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(
 async def get_external_link(
     link_id: int,
     link_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
 ):
 ):
     """Get a specific external link."""
     """Get a specific external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -83,6 +91,7 @@ async def update_external_link(
     link_id: int,
     link_id: int,
     update_data: ExternalLinkUpdate,
     update_data: ExternalLinkUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
 ):
     """Update an external link."""
     """Update an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     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(
 async def delete_external_link(
     link_id: int,
     link_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
 ):
 ):
     """Delete an external link."""
     """Delete an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     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(
 async def reorder_external_links(
     reorder_data: ExternalLinkReorder,
     reorder_data: ExternalLinkReorder,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
 ):
     """Update the sort order of external links."""
     """Update the sort order of external links."""
     # Update sort_order for each link based on position in the list
     # Update sort_order for each link based on position in the list
@@ -154,6 +165,7 @@ async def upload_icon(
     link_id: int,
     link_id: int,
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
 ):
     """Upload a custom icon for an external link."""
     """Upload a custom icon for an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -202,6 +214,7 @@ async def upload_icon(
 async def delete_icon(
 async def delete_icon(
     link_id: int,
     link_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
 ):
     """Delete the custom icon for an external link."""
     """Delete the custom icon for an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -227,7 +240,10 @@ async def get_icon(
     link_id: int,
     link_id: int,
     db: AsyncSession = Depends(get_db),
     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))
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
     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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
+from backend.app.models.user import User
 from backend.app.schemas.filament import (
 from backend.app.schemas.filament import (
     FilamentCostCalculation,
     FilamentCostCalculation,
     FilamentCreate,
     FilamentCreate,
@@ -15,7 +18,10 @@ router = APIRouter(prefix="/filaments", tags=["filaments"])
 
 
 
 
 @router.get("/", response_model=list[FilamentResponse])
 @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."""
     """List all filaments."""
     result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))
     result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))
     return list(result.scalars().all())
     return list(result.scalars().all())
@@ -25,6 +31,7 @@ async def list_filaments(db: AsyncSession = Depends(get_db)):
 async def create_filament(
 async def create_filament(
     filament_data: FilamentCreate,
     filament_data: FilamentCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
 ):
 ):
     """Create a new filament entry."""
     """Create a new filament entry."""
     filament = Filament(**filament_data.model_dump())
     filament = Filament(**filament_data.model_dump())
@@ -35,7 +42,11 @@ async def create_filament(
 
 
 
 
 @router.get("/{filament_id}", response_model=FilamentResponse)
 @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."""
     """Get a specific filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
     filament = result.scalar_one_or_none()
@@ -49,6 +60,7 @@ async def update_filament(
     filament_id: int,
     filament_id: int,
     filament_data: FilamentUpdate,
     filament_data: FilamentUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
 ):
     """Update a filament."""
     """Update a filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
@@ -65,7 +77,11 @@ async def update_filament(
 
 
 
 
 @router.delete("/{filament_id}")
 @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."""
     """Delete a filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
     filament = result.scalar_one_or_none()
@@ -82,6 +98,7 @@ async def calculate_cost(
     filament_id: int,
     filament_id: int,
     weight_grams: float,
     weight_grams: float,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Calculate the cost for a given weight of filament."""
     """Calculate the cost for a given weight of filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     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(
 async def get_filaments_by_type(
     filament_type: str,
     filament_type: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
 ):
     """Get all filaments of a specific type."""
     """Get all filaments of a specific type."""
     result = await db.execute(select(Filament).where(Filament.type.ilike(f"%{filament_type}%")).order_by(Filament.name))
     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")
 @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."""
     """Seed the database with common filament types."""
     defaults = [
     defaults = [
         {
         {

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

@@ -12,8 +12,11 @@ from pydantic import BaseModel, Field
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 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_check import get_firmware_service
 from backend.app.services.firmware_update import (
 from backend.app.services.firmware_update import (
     FirmwareUploadStatus,
     FirmwareUploadStatus,
@@ -59,6 +62,7 @@ class LatestFirmwareInfo(BaseModel):
 @router.get("/updates", response_model=FirmwareUpdatesResponse)
 @router.get("/updates", response_model=FirmwareUpdatesResponse)
 async def check_firmware_updates(
 async def check_firmware_updates(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
     Check for firmware updates for all connected printers.
     Check for firmware updates for all connected printers.
@@ -112,6 +116,7 @@ async def check_firmware_updates(
 async def check_printer_firmware(
 async def check_printer_firmware(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
     Check for firmware update for a specific printer.
     Check for firmware update for a specific printer.
@@ -148,7 +153,9 @@ async def check_printer_firmware(
 
 
 
 
 @router.get("/latest", response_model=list[LatestFirmwareInfo])
 @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.
     Get the latest firmware versions for all Bambu Lab printer models.
 
 
@@ -211,6 +218,7 @@ class FirmwareUploadStartResponse(BaseModel):
 async def prepare_firmware_upload(
 async def prepare_firmware_upload(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
     Check prerequisites for uploading firmware to a printer.
     Check prerequisites for uploading firmware to a printer.
@@ -232,6 +240,7 @@ async def prepare_firmware_upload(
 async def start_firmware_upload(
 async def start_firmware_upload(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
 ):
 ):
     """
     """
     Start uploading firmware to a printer's SD card.
     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)
 @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.
     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 import delete, desc, select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
 from backend.app.schemas.github_backup import (
     GitHubBackupConfigCreate,
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
     GitHubBackupConfigResponse,
@@ -48,7 +51,10 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
 
 
 
 
 @router.get("/config", response_model=GitHubBackupConfigResponse | None)
 @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."""
     """Get the current GitHub backup configuration."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
     config = result.scalar_one_or_none()
@@ -63,6 +69,7 @@ async def get_config(db: AsyncSession = Depends(get_db)):
 async def save_config(
 async def save_config(
     config_data: GitHubBackupConfigCreate,
     config_data: GitHubBackupConfigCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Create or update GitHub backup configuration.
     """Create or update GitHub backup configuration.
 
 
@@ -121,6 +128,7 @@ async def save_config(
 async def update_config(
 async def update_config(
     update_data: GitHubBackupConfigUpdate,
     update_data: GitHubBackupConfigUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Partially update GitHub backup configuration."""
     """Partially update GitHub backup configuration."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -153,7 +161,10 @@ async def update_config(
 
 
 
 
 @router.delete("/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."""
     """Delete the GitHub backup configuration and all logs."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
     config = result.scalar_one_or_none()
@@ -173,6 +184,7 @@ async def delete_config(db: AsyncSession = Depends(get_db)):
 async def test_connection(
 async def test_connection(
     repo_url: str = Query(..., description="GitHub repository URL"),
     repo_url: str = Query(..., description="GitHub repository URL"),
     token: str = Query(..., description="Personal Access Token"),
     token: str = Query(..., description="Personal Access Token"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Test GitHub connection with provided credentials."""
     """Test GitHub connection with provided credentials."""
     result = await github_backup_service.test_connection(repo_url, token)
     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)
 @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."""
     """Test GitHub connection using stored configuration."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
     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)
 @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."""
     """Manually trigger a backup."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
     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)
 @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."""
     """Get current backup status."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
     config = result.scalar_one_or_none()
@@ -245,6 +266,7 @@ async def get_logs(
     limit: int = Query(default=50, ge=1, le=200),
     limit: int = Query(default=50, ge=1, le=200),
     offset: int = Query(default=0, ge=0),
     offset: int = Query(default=0, ge=0),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Get backup logs."""
     """Get backup logs."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -282,6 +304,7 @@ async def get_logs(
 async def clear_logs(
 async def clear_logs(
     keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
     keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
 ):
     """Clear backup logs, optionally keeping the most recent entries."""
     """Clear backup logs, optionally keeping the most recent entries."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.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.kprofile_note import KProfileNote as KProfileNoteModel
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.schemas.kprofile import (
 from backend.app.schemas.kprofile import (
     KProfile,
     KProfile,
     KProfileCreate,
     KProfileCreate,
@@ -30,6 +33,7 @@ async def get_kprofiles(
     printer_id: int,
     printer_id: int,
     nozzle_diameter: str = "0.4",
     nozzle_diameter: str = "0.4",
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
 ):
     """Get K-profiles from a printer.
     """Get K-profiles from a printer.
 
 
@@ -78,6 +82,7 @@ async def set_kprofile(
     printer_id: int,
     printer_id: int,
     profile: KProfileCreate,
     profile: KProfileCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
 ):
     """Create or update a K-profile on the printer.
     """Create or update a K-profile on the printer.
 
 
@@ -178,6 +183,7 @@ async def set_kprofiles_batch(
     printer_id: int,
     printer_id: int,
     profiles: list[KProfileCreate],
     profiles: list[KProfileCreate],
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
 ):
     """Create multiple K-profiles in a single command (for dual-nozzle).
     """Create multiple K-profiles in a single command (for dual-nozzle).
 
 
@@ -236,6 +242,7 @@ async def delete_kprofile(
     printer_id: int,
     printer_id: int,
     profile: KProfileDelete,
     profile: KProfileDelete,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
 ):
     """Delete a K-profile from the printer.
     """Delete a K-profile from the printer.
 
 
@@ -278,6 +285,7 @@ async def delete_kprofile(
 async def get_kprofile_notes(
 async def get_kprofile_notes(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
 ):
     """Get all K-profile notes for a printer.
     """Get all K-profile notes for a printer.
 
 
@@ -305,6 +313,7 @@ async def set_kprofile_note(
     printer_id: int,
     printer_id: int,
     note_data: KProfileNote,
     note_data: KProfileNote,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
 ):
     """Set or update a note for a K-profile.
     """Set or update a note for a K-profile.
 
 
@@ -353,6 +362,7 @@ async def delete_kprofile_note(
     printer_id: int,
     printer_id: int,
     setting_id: str,
     setting_id: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
 ):
     """Delete a note for a K-profile.
     """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.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 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.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.schemas.maintenance import (
 from backend.app.schemas.maintenance import (
     MaintenanceHistoryResponse,
     MaintenanceHistoryResponse,
     MaintenanceStatus,
     MaintenanceStatus,
@@ -114,7 +117,10 @@ async def ensure_default_types(db: AsyncSession) -> None:
 
 
 
 
 @router.get("/types", response_model=list[MaintenanceTypeResponse])
 @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."""
     """Get all maintenance types."""
     await ensure_default_types(db)
     await ensure_default_types(db)
     result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
     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(
 async def create_maintenance_type(
     data: MaintenanceTypeCreate,
     data: MaintenanceTypeCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
 ):
     """Create a custom maintenance type."""
     """Create a custom maintenance type."""
     new_type = MaintenanceType(
     new_type = MaintenanceType(
@@ -146,6 +153,7 @@ async def update_maintenance_type(
     type_id: int,
     type_id: int,
     data: MaintenanceTypeUpdate,
     data: MaintenanceTypeUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
 ):
     """Update a maintenance type."""
     """Update a maintenance type."""
     result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
     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(
 async def delete_maintenance_type(
     type_id: int,
     type_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
 ):
     """Delete a custom maintenance type."""
     """Delete a custom maintenance type."""
     result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
     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(
 async def get_printer_maintenance(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
 ):
     """Get maintenance overview for a specific printer."""
     """Get maintenance overview for a specific printer."""
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
 
 
 
 
 @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
 @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."""
     """Get maintenance overview for all active printers."""
     await ensure_default_types(db)
     await ensure_default_types(db)
 
 
@@ -361,6 +374,7 @@ async def update_printer_maintenance(
     item_id: int,
     item_id: int,
     data: PrinterMaintenanceUpdate,
     data: PrinterMaintenanceUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
 ):
     """Update a printer maintenance item (e.g., custom interval, enabled)."""
     """Update a printer maintenance item (e.g., custom interval, enabled)."""
     result = await db.execute(
     result = await db.execute(
@@ -386,6 +400,7 @@ async def assign_maintenance_type(
     printer_id: int,
     printer_id: int,
     type_id: int,
     type_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
 ):
     """Assign a maintenance type to a specific printer (for custom types)."""
     """Assign a maintenance type to a specific printer (for custom types)."""
     # Verify printer exists
     # Verify printer exists
@@ -438,6 +453,7 @@ async def assign_maintenance_type(
 async def remove_maintenance_item(
 async def remove_maintenance_item(
     item_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
 ):
     """Remove a maintenance item (unassign a custom type from a printer)."""
     """Remove a maintenance item (unassign a custom type from a printer)."""
     result = await db.execute(
     result = await db.execute(
@@ -464,6 +480,7 @@ async def perform_maintenance(
     item_id: int,
     item_id: int,
     data: PerformMaintenanceRequest,
     data: PerformMaintenanceRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
 ):
     """Mark maintenance as performed (reset the counter)."""
     """Mark maintenance as performed (reset the counter)."""
     result = await db.execute(
     result = await db.execute(
@@ -541,6 +558,7 @@ async def perform_maintenance(
 async def get_maintenance_history(
 async def get_maintenance_history(
     item_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
 ):
     """Get maintenance history for a specific item."""
     """Get maintenance history for a specific item."""
     result = await db.execute(
     result = await db.execute(
@@ -552,7 +570,10 @@ async def get_maintenance_history(
 
 
 
 
 @router.get("/summary")
 @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."""
     """Get a summary of maintenance status across all printers."""
     await ensure_default_types(db)
     await ensure_default_types(db)
 
 
@@ -589,6 +610,7 @@ async def set_printer_hours(
     printer_id: int,
     printer_id: int,
     total_hours: float,
     total_hours: float,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
 ):
     """Set the total print hours for a printer (adjusts offset to match).
     """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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.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.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
+from backend.app.models.user import User
 from backend.app.schemas.notification_template import (
 from backend.app.schemas.notification_template import (
     EVENT_VARIABLES,
     EVENT_VARIABLES,
     SAMPLE_DATA,
     SAMPLE_DATA,
@@ -45,14 +48,19 @@ EVENT_NAMES = {
 
 
 @router.get("", response_model=list[NotificationTemplateResponse])
 @router.get("", response_model=list[NotificationTemplateResponse])
 @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."""
     """Get all notification templates."""
     result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
     result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
     return result.scalars().all()
     return result.scalars().all()
 
 
 
 
 @router.get("/variables", response_model=list[EventVariablesResponse])
 @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."""
     """Get available variables for each event type."""
     return [
     return [
         EventVariablesResponse(
         EventVariablesResponse(
@@ -65,7 +73,11 @@ async def get_variables():
 
 
 
 
 @router.get("/{template_id}", response_model=NotificationTemplateResponse)
 @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."""
     """Get a single notification template."""
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
     template = result.scalar_one_or_none()
@@ -79,6 +91,7 @@ async def update_template(
     template_id: int,
     template_id: int,
     update: NotificationTemplateUpdate,
     update: NotificationTemplateUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
 ):
 ):
     """Update a notification template."""
     """Update a notification template."""
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     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)
 @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."""
     """Reset a notification template to its default values."""
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
     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)
 @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."""
     """Preview a template with sample data."""
     sample = SAMPLE_DATA.get(request.event_type, {})
     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 import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.notification import NotificationLog, NotificationProvider
 from backend.app.models.notification import NotificationLog, NotificationProvider
+from backend.app.models.user import User
 from backend.app.schemas.notification import (
 from backend.app.schemas.notification import (
     NotificationLogResponse,
     NotificationLogResponse,
     NotificationLogStats,
     NotificationLogStats,
@@ -86,7 +89,10 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
 
 
 
 
 @router.get("/", response_model=list[NotificationProviderResponse])
 @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."""
     """List all notification providers."""
     result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
     result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
     providers = result.scalars().all()
     providers = result.scalars().all()
@@ -98,6 +104,7 @@ async def list_notification_providers(db: AsyncSession = Depends(get_db)):
 async def create_notification_provider(
 async def create_notification_provider(
     provider_data: NotificationProviderCreate,
     provider_data: NotificationProviderCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
 ):
 ):
     """Create a new notification provider."""
     """Create a new notification provider."""
     provider = NotificationProvider(
     provider = NotificationProvider(
@@ -153,6 +160,7 @@ async def create_notification_provider(
 async def test_notification_config(
 async def test_notification_config(
     test_request: NotificationTestRequest,
     test_request: NotificationTestRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
 ):
 ):
     """Test notification configuration before saving."""
     """Test notification configuration before saving."""
     success, message = await notification_service.send_test_notification(
     success, message = await notification_service.send_test_notification(
@@ -163,7 +171,10 @@ async def test_notification_config(
 
 
 
 
 @router.post("/test-all")
 @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."""
     """Send a test notification to all enabled providers."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
     providers = result.scalars().all()
     providers = result.scalars().all()
@@ -222,6 +233,7 @@ async def get_notification_logs(
     success: bool | None = Query(default=None),
     success: bool | None = Query(default=None),
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
 ):
     """Get notification logs with optional filters."""
     """Get notification logs with optional filters."""
     query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
     query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
@@ -278,6 +290,7 @@ async def get_notification_logs(
 async def get_notification_log_stats(
 async def get_notification_log_stats(
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
 ):
     """Get notification log statistics."""
     """Get notification log statistics."""
     cutoff = datetime.utcnow() - timedelta(days=days)
     cutoff = datetime.utcnow() - timedelta(days=days)
@@ -323,6 +336,7 @@ async def get_notification_log_stats(
 async def clear_notification_logs(
 async def clear_notification_logs(
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
 ):
 ):
     """Clear old notification logs."""
     """Clear old notification logs."""
     cutoff = datetime.utcnow() - timedelta(days=older_than_days)
     cutoff = datetime.utcnow() - timedelta(days=older_than_days)
@@ -345,6 +359,7 @@ async def clear_notification_logs(
 async def get_notification_provider(
 async def get_notification_provider(
     provider_id: int,
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
 ):
     """Get a specific notification provider."""
     """Get a specific notification provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -361,6 +376,7 @@ async def update_notification_provider(
     provider_id: int,
     provider_id: int,
     update_data: NotificationProviderUpdate,
     update_data: NotificationProviderUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
 ):
 ):
     """Update a notification provider."""
     """Update a notification provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
     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(
 async def delete_notification_provider(
     provider_id: int,
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
 ):
 ):
     """Delete a notification provider."""
     """Delete a notification provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
     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(
 async def test_notification_provider(
     provider_id: int,
     provider_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
 ):
 ):
     """Send a test notification using an existing provider."""
     """Send a test notification using an existing provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
     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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.user import User
 from backend.app.services.archive import ArchiveService
 from backend.app.services.archive import ArchiveService
 
 
 router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
 router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
@@ -41,7 +44,10 @@ class PendingUploadResponse(BaseModel):
 
 
 
 
 @router.get("/", response_model=list[PendingUploadResponse])
 @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."""
     """List all pending uploads."""
     result = await db.execute(
     result = await db.execute(
         select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
         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")
 @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."""
     """Get count of pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     count = len(result.scalars().all())
     count = len(result.scalars().all())
@@ -64,7 +73,10 @@ async def get_pending_count(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.post("/archive-all")
 @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."""
     """Archive all pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     pending_uploads = result.scalars().all()
     pending_uploads = result.scalars().all()
@@ -117,7 +129,10 @@ async def archive_all_pending(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.delete("/discard-all")
 @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."""
     """Discard all pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     pending_uploads = result.scalars().all()
     pending_uploads = result.scalars().all()
@@ -144,6 +159,7 @@ async def discard_all_pending(db: AsyncSession = Depends(get_db)):
 async def get_pending_upload(
 async def get_pending_upload(
     upload_id: int,
     upload_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
 ):
 ):
     """Get a specific pending upload."""
     """Get a specific pending upload."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
@@ -160,6 +176,7 @@ async def archive_pending_upload(
     upload_id: int,
     upload_id: int,
     request: ArchiveRequest = None,
     request: ArchiveRequest = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
 ):
     """Archive a pending upload."""
     """Archive a pending upload."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
     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(
 async def discard_pending_upload(
     upload_id: int,
     upload_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
 ):
 ):
     """Discard a pending upload without archiving."""
     """Discard a pending upload without archiving."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
     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.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 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.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 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)"),
     printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
     status: str | None = Query(None, description="Filter by status"),
     status: str | None = Query(None, description="Filter by status"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
 ):
 ):
     """List all queue items, optionally filtered by printer or status."""
     """List all queue items, optionally filtered by printer or status."""
     query = (
     query = (
@@ -204,7 +205,7 @@ async def list_queue(
 async def add_to_queue(
 async def add_to_queue(
     data: PrintQueueItemCreate,
     data: PrintQueueItemCreate,
     db: AsyncSession = Depends(get_db),
     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."""
     """Add an item to the print queue."""
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
     # 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)
 @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."""
     """Get a specific queue item."""
     result = await db.execute(
     result = await db.execute(
         select(PrintQueueItem)
         select(PrintQueueItem)
@@ -553,6 +558,7 @@ async def delete_queue_item(
 async def reorder_queue(
 async def reorder_queue(
     data: PrintQueueReorder,
     data: PrintQueueReorder,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
 ):
     """Bulk update positions for queue items."""
     """Bulk update positions for queue items."""
     for reorder_item in data.items:
     for reorder_item in data.items:
@@ -605,6 +611,7 @@ async def cancel_queue_item(
 async def stop_queue_item(
 async def stop_queue_item(
     item_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
 ):
     """Stop an actively printing queue item."""
     """Stop an actively printing queue item."""
     import asyncio
     import asyncio
@@ -675,6 +682,7 @@ async def stop_queue_item(
 async def start_queue_item(
 async def start_queue_item(
     item_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
 ):
 ):
     """Manually start a staged (manual_start) queue item.
     """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,
     ams_id: int,
     tray_id: int,
     tray_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
 ):
     """Reset an AMS slot to empty/unconfigured state.
     """Reset an AMS slot to empty/unconfigured state.
 
 
@@ -1403,6 +1404,7 @@ async def reset_ams_slot(
 async def debug_simulate_print_complete(
 async def debug_simulate_print_complete(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
 ):
     """DEBUG: Simulate print completion to test freeze behavior.
     """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 sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes.library import get_library_dir
 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.config import settings
 from backend.app.core.database import get_db
 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.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.models.project_bom import ProjectBOMItem
+from backend.app.models.user import User
 from backend.app.schemas.project import (
 from backend.app.schemas.project import (
     ArchivePreview,
     ArchivePreview,
     BatchAddArchives,
     BatchAddArchives,
@@ -154,6 +157,7 @@ async def compute_project_stats(
 async def list_projects(
 async def list_projects(
     status: str | None = None,
     status: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
 ):
     """List all projects with basic stats."""
     """List all projects with basic stats."""
     query = select(Project)
     query = select(Project)
@@ -258,6 +262,7 @@ async def list_projects(
 async def create_project(
 async def create_project(
     data: ProjectCreate,
     data: ProjectCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
 ):
     """Create a new project."""
     """Create a new project."""
     # Verify parent exists if specified
     # Verify parent exists if specified
@@ -319,6 +324,7 @@ async def create_project(
 @router.get("/templates", response_model=list[ProjectListResponse])
 @router.get("/templates", response_model=list[ProjectListResponse])
 async def list_templates(
 async def list_templates(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
 ):
     """List all project templates."""
     """List all project templates."""
     result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
     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,
     template_id: int,
     name: str = None,
     name: str = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
 ):
     """Create a new project from a template."""
     """Create a new project from a template."""
     result = await db.execute(select(Project).where(Project.id == template_id))
     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(
 async def get_project(
     project_id: int,
     project_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
 ):
     """Get a project by ID with detailed stats."""
     """Get a project by ID with detailed stats."""
     result = await db.execute(select(Project).where(Project.id == project_id))
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -519,6 +527,7 @@ async def update_project(
     project_id: int,
     project_id: int,
     data: ProjectUpdate,
     data: ProjectUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Update a project."""
     """Update a project."""
     result = await db.execute(select(Project).where(Project.id == project_id))
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -609,6 +618,7 @@ async def update_project(
 async def delete_project(
 async def delete_project(
     project_id: int,
     project_id: int,
     db: AsyncSession = Depends(get_db),
     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."""
     """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))
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -628,6 +638,7 @@ async def list_project_archives(
     limit: int = 100,
     limit: int = 100,
     offset: int = 0,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
 ):
     """List archives in a project."""
     """List archives in a project."""
     # Verify project exists
     # Verify project exists
@@ -657,6 +668,7 @@ async def list_project_archives(
 async def list_project_queue(
 async def list_project_queue(
     project_id: int,
     project_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
 ):
     """List queue items in a project."""
     """List queue items in a project."""
     # Verify project exists
     # Verify project exists
@@ -677,6 +689,7 @@ async def add_archives_to_project(
     project_id: int,
     project_id: int,
     data: BatchAddArchives,
     data: BatchAddArchives,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Batch add archives to a project."""
     """Batch add archives to a project."""
     # Verify project exists
     # Verify project exists
@@ -701,6 +714,7 @@ async def add_queue_items_to_project(
     project_id: int,
     project_id: int,
     data: BatchAddQueueItems,
     data: BatchAddQueueItems,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Batch add queue items to a project."""
     """Batch add queue items to a project."""
     # Verify project exists
     # Verify project exists
@@ -725,6 +739,7 @@ async def remove_archives_from_project(
     project_id: int,
     project_id: int,
     data: BatchAddArchives,
     data: BatchAddArchives,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Remove archives from a project (sets project_id to NULL)."""
     """Remove archives from a project (sets project_id to NULL)."""
     updated = 0
     updated = 0
@@ -811,6 +826,7 @@ async def upload_attachment(
     project_id: int,
     project_id: int,
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Upload an attachment to a project."""
     """Upload an attachment to a project."""
     logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
     logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
@@ -888,6 +904,7 @@ async def download_attachment(
     project_id: int,
     project_id: int,
     filename: str,
     filename: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
 ):
     """Download an attachment from a project."""
     """Download an attachment from a project."""
     # Verify project exists
     # Verify project exists
@@ -919,6 +936,7 @@ async def delete_attachment(
     project_id: int,
     project_id: int,
     filename: str,
     filename: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Delete an attachment from a project."""
     """Delete an attachment from a project."""
     # Verify project exists
     # Verify project exists
@@ -962,6 +980,7 @@ async def delete_attachment(
 async def list_bom_items(
 async def list_bom_items(
     project_id: int,
     project_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
 ):
     """List all BOM items for a project."""
     """List all BOM items for a project."""
     # Verify project exists
     # Verify project exists
@@ -1013,6 +1032,7 @@ async def create_bom_item(
     project_id: int,
     project_id: int,
     data: BOMItemCreate,
     data: BOMItemCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Add a BOM item to a project."""
     """Add a BOM item to a project."""
     # Verify project exists
     # Verify project exists
@@ -1072,6 +1092,7 @@ async def update_bom_item(
     item_id: int,
     item_id: int,
     data: BOMItemUpdate,
     data: BOMItemUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Update a BOM item."""
     """Update a BOM item."""
     result = await db.execute(
     result = await db.execute(
@@ -1135,6 +1156,7 @@ async def delete_bom_item(
     project_id: int,
     project_id: int,
     item_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
 ):
     """Delete a BOM item."""
     """Delete a BOM item."""
     result = await db.execute(
     result = await db.execute(
@@ -1157,6 +1179,7 @@ async def delete_bom_item(
 async def create_template_from_project(
 async def create_template_from_project(
     project_id: int,
     project_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
 ):
     """Create a template from an existing project."""
     """Create a template from an existing project."""
     result = await db.execute(select(Project).where(Project.id == project_id))
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -1238,6 +1261,7 @@ async def get_project_timeline(
     project_id: int,
     project_id: int,
     limit: int = 50,
     limit: int = 50,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
 ):
     """Get timeline of events for a project."""
     """Get timeline of events for a project."""
     # Verify project exists
     # Verify project exists
@@ -1338,6 +1362,7 @@ async def export_project(
     project_id: int,
     project_id: int,
     format: str = "zip",  # "zip" (with files) or "json" (metadata only)
     format: str = "zip",  # "zip" (with files) or "json" (metadata only)
     db: AsyncSession = Depends(get_db),
     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."""
     """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))
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -1458,6 +1483,7 @@ async def export_project(
 async def import_project(
 async def import_project(
     data: ProjectImport,
     data: ProjectImport,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
 ):
     """Import a project with optional BOM items and linked folders."""
     """Import a project with optional BOM items and linked folders."""
     # Create the project
     # Create the project
@@ -1551,6 +1577,7 @@ async def import_project(
 async def import_project_file(
 async def import_project_file(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
 ):
     """Import a project from a ZIP or JSON file."""
     """Import a project from a ZIP or JSON file."""
     if not file.filename:
     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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.config import settings as app_settings
 from backend.app.core.database import get_db
 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.settings import Settings
+from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 
 
 router = APIRouter(prefix="/settings", tags=["settings"])
 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)
 @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."""
     """Get all application settings."""
     settings_dict = DEFAULT_SETTINGS.model_dump()
     settings_dict = DEFAULT_SETTINGS.model_dump()
 
 
@@ -96,6 +102,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
 async def update_settings(
 async def update_settings(
     settings_update: AppSettingsUpdate,
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Update application settings."""
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
     update_data = settings_update.model_dump(exclude_unset=True)
@@ -153,13 +160,17 @@ async def update_settings(
 async def patch_settings(
 async def patch_settings(
     settings_update: AppSettingsUpdate,
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Partially update application settings (same as PUT, for REST compatibility)."""
     """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)
 @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."""
     """Reset all settings to defaults."""
     # Delete all settings
     # Delete all settings
     result = await db.execute(select(Settings))
     result = await db.execute(select(Settings))
@@ -185,7 +196,10 @@ async def check_ffmpeg():
 
 
 
 
 @router.get("/spoolman")
 @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."""
     """Get Spoolman integration settings."""
     spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
     spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
     spoolman_url = await get_setting(db, "spoolman_url") or ""
     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(
 async def update_spoolman_settings(
     settings: dict,
     settings: dict,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Update Spoolman integration settings."""
     """Update Spoolman integration settings."""
     if "spoolman_enabled" in settings:
     if "spoolman_enabled" in settings:
@@ -219,7 +234,10 @@ async def update_spoolman_settings(
 
 
 
 
 @router.get("/backup")
 @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.
     """Create a complete backup (database + all files) as a ZIP.
 
 
     This is a simplified backup that includes the entire SQLite database
     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(
 async def restore_backup(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
 ):
     """Restore from a complete backup ZIP.
     """Restore from a complete backup ZIP.
 
 
@@ -363,7 +382,10 @@ async def get_virtual_printer_models():
 
 
 
 
 @router.get("/virtual-printer")
 @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."""
     """Get virtual printer settings and status."""
     from backend.app.services.virtual_printer import (
     from backend.app.services.virtual_printer import (
         DEFAULT_VIRTUAL_PRINTER_MODEL,
         DEFAULT_VIRTUAL_PRINTER_MODEL,
@@ -391,6 +413,7 @@ async def update_virtual_printer_settings(
     mode: str = None,
     mode: str = None,
     model: str = None,
     model: str = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Update virtual printer settings and restart services if needed."""
     """Update virtual printer settings and restart services if needed."""
     from backend.app.services.virtual_printer import (
     from backend.app.services.virtual_printer import (
@@ -482,7 +505,9 @@ async def update_virtual_printer_settings(
 
 
 
 
 @router.get("/mqtt/status")
 @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."""
     """Get MQTT relay connection status."""
     from backend.app.services.mqtt_relay import mqtt_relay
     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 sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes.settings import get_setting
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 from backend.app.schemas.smart_plug import (
 from backend.app.schemas.smart_plug import (
     HAEntity,
     HAEntity,
     HASensorEntity,
     HASensorEntity,
@@ -38,7 +41,10 @@ router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 
 
 
 
 @router.get("/", response_model=list[SmartPlugResponse])
 @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."""
     """List all smart plugs."""
     result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
     result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
     return list(result.scalars().all())
     return list(result.scalars().all())
@@ -48,6 +54,7 @@ async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
 async def create_smart_plug(
 async def create_smart_plug(
     data: SmartPlugCreate,
     data: SmartPlugCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
 ):
 ):
     """Create a new smart plug."""
     """Create a new smart plug."""
     # Validate printer_id if provided
     # Validate printer_id if provided
@@ -142,7 +149,11 @@ async def create_smart_plug(
 
 
 
 
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 @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.
     """Get the main smart plug assigned to a printer.
 
 
     When multiple plugs are assigned (e.g., a regular plug + script),
     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])
 @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.
     """Get all HA script plugs assigned to a printer.
 
 
     Returns only script entities (script.*) for the printer that have
     Returns only script entities (script.*) for the printer that have
@@ -244,7 +259,10 @@ class DiscoveredTasmotaDevice(BaseModel):
 
 
 
 
 @router.post("/discover/scan", response_model=TasmotaScanStatus)
 @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.
     """Start an IP range scan for Tasmota devices.
 
 
     Auto-detects local network if no IP range provided.
     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)
 @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."""
     """Get the current Tasmota scan status."""
     scanned, total = tasmota_scanner.progress
     scanned, total = tasmota_scanner.progress
     return TasmotaScanStatus(
     return TasmotaScanStatus(
@@ -279,7 +299,9 @@ async def get_tasmota_scan_status():
 
 
 
 
 @router.post("/discover/stop", response_model=TasmotaScanStatus)
 @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."""
     """Stop the current Tasmota scan."""
     tasmota_scanner.stop()
     tasmota_scanner.stop()
     scanned, total = tasmota_scanner.progress
     scanned, total = tasmota_scanner.progress
@@ -291,7 +313,9 @@ async def stop_tasmota_scan():
 
 
 
 
 @router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
 @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."""
     """Get list of discovered Tasmota devices."""
     return [
     return [
         DiscoveredTasmotaDevice(
         DiscoveredTasmotaDevice(
@@ -309,7 +333,10 @@ async def get_discovered_tasmota_devices():
 
 
 
 
 @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
 @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."""
     """Test connection to Home Assistant."""
     result = await homeassistant_service.test_connection(request.url, request.token)
     result = await homeassistant_service.test_connection(request.url, request.token)
     return HATestConnectionResponse(**result)
     return HATestConnectionResponse(**result)
@@ -319,6 +346,7 @@ async def test_ha_connection(request: HATestConnectionRequest):
 async def list_ha_entities(
 async def list_ha_entities(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     search: str | None = None,
     search: str | None = None,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
 ):
     """List available Home Assistant entities.
     """List available Home Assistant entities.
 
 
@@ -340,7 +368,10 @@ async def list_ha_entities(
 
 
 
 
 @router.get("/ha/sensors", response_model=list[HASensorEntity])
 @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.
     """List available Home Assistant sensor entities for energy monitoring.
 
 
     Returns sensors with power/energy units (W, kW, kWh, Wh).
     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)
 @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."""
     """Get a specific smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
@@ -373,6 +408,7 @@ async def update_smart_plug(
     plug_id: int,
     plug_id: int,
     data: SmartPlugUpdate,
     data: SmartPlugUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
 ):
 ):
     """Update a smart plug."""
     """Update a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
@@ -489,7 +525,11 @@ async def update_smart_plug(
 
 
 
 
 @router.delete("/{plug_id}")
 @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."""
     """Delete a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
@@ -529,6 +569,7 @@ async def control_smart_plug(
     plug_id: int,
     plug_id: int,
     control: SmartPlugControl,
     control: SmartPlugControl,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
 ):
 ):
     """Manual control: on/off/toggle."""
     """Manual control: on/off/toggle."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     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)
 @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."""
     """Get current plug status from device including energy data."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     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")
 @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."""
     """Test connection to a Tasmota device."""
     result = await tasmota_service.test_connection(
     result = await tasmota_service.test_connection(
         data.ip_address,
         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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 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.printer_manager import printer_manager
 from backend.app.services.spoolman import (
 from backend.app.services.spoolman import (
     close_spoolman_client,
     close_spoolman_client,
@@ -72,7 +75,10 @@ async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
 
 
 
 
 @router.get("/status", response_model=SpoolmanStatus)
 @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."""
     """Get Spoolman integration status."""
     enabled, url, _ = await get_spoolman_settings(db)
     enabled, url, _ = await get_spoolman_settings(db)
 
 
@@ -89,7 +95,10 @@ async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.post("/connect")
 @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."""
     """Connect to Spoolman server using configured URL."""
     enabled, url, _ = await get_spoolman_settings(db)
     enabled, url, _ = await get_spoolman_settings(db)
 
 
@@ -119,7 +128,9 @@ async def connect_spoolman(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.post("/disconnect")
 @router.post("/disconnect")
-async def disconnect_spoolman():
+async def disconnect_spoolman(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Disconnect from Spoolman server."""
     """Disconnect from Spoolman server."""
     await close_spoolman_client()
     await close_spoolman_client()
     return {"success": True, "message": "Disconnected from Spoolman"}
     return {"success": True, "message": "Disconnected from Spoolman"}
@@ -129,6 +140,7 @@ async def disconnect_spoolman():
 async def sync_printer_ams(
 async def sync_printer_ams(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
 ):
     """Sync AMS data from a specific printer to Spoolman."""
     """Sync AMS data from a specific printer to Spoolman."""
     # Check if Spoolman is enabled and connected
     # Check if Spoolman is enabled and connected
@@ -267,7 +279,10 @@ async def sync_printer_ams(
 
 
 
 
 @router.post("/sync-all", response_model=SyncResult)
 @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."""
     """Sync AMS data from all connected printers to Spoolman."""
     # Check if Spoolman is enabled
     # Check if Spoolman is enabled
     enabled, url, _ = await get_spoolman_settings(db)
     enabled, url, _ = await get_spoolman_settings(db)
@@ -392,7 +407,10 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.get("/spools")
 @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."""
     """Get all spools from Spoolman."""
     enabled, url, _ = await get_spoolman_settings(db)
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
     if not enabled:
@@ -413,7 +431,10 @@ async def get_spools(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.get("/filaments")
 @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."""
     """Get all filaments from Spoolman."""
     enabled, url, _ = await get_spoolman_settings(db)
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
     if not enabled:
@@ -445,7 +466,10 @@ class UnlinkedSpool(BaseModel):
 
 
 
 
 @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
 @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)."""
     """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
     enabled, url, _ = await get_spoolman_settings(db)
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
     if not enabled:
@@ -487,7 +511,10 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.get("/spools/linked")
 @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."""
     """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
     enabled, url, _ = await get_spoolman_settings(db)
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
     if not enabled:
@@ -530,6 +557,7 @@ async def link_spool(
     spool_id: int,
     spool_id: int,
     request: LinkSpoolRequest,
     request: LinkSpoolRequest,
     db: AsyncSession = Depends(get_db),
     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."""
     """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
     enabled, url, _ = await get_spoolman_settings(db)
     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 import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.config import APP_VERSION, settings
 from backend.app.core.database import async_session
 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.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 
 
 router = APIRouter(prefix="/support", tags=["support"])
 router = APIRouter(prefix="/support", tags=["support"])
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -107,7 +110,9 @@ def _apply_log_level(debug: bool):
 
 
 
 
 @router.get("/debug-logging", response_model=DebugLoggingState)
 @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."""
     """Get current debug logging state."""
     global _debug_logging_enabled, _debug_logging_enabled_at
     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)
 @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."""
     """Enable or disable debug logging."""
     global _debug_logging_enabled, _debug_logging_enabled_at
     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"),
     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)"),
     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"),
     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."""
     """Get recent application log entries with optional filtering."""
     entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
     entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
@@ -285,7 +294,9 @@ async def get_logs(
 
 
 
 
 @router.delete("/logs")
 @router.delete("/logs")
-async def clear_logs():
+async def clear_logs(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Clear the application log file."""
     """Clear the application log file."""
     log_file = settings.log_dir / "bambuddy.log"
     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")
 @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."""
     """Generate a support bundle ZIP file for issue reporting."""
     global _debug_logging_enabled, _debug_logging_enabled_at
     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 import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.config import APP_VERSION, settings
 from backend.app.core.database import get_db
 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.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 
 
 router = APIRouter(prefix="/system", tags=["system"])
 router = APIRouter(prefix="/system", tags=["system"])
@@ -60,7 +63,10 @@ def format_uptime(seconds: float) -> str:
 
 
 
 
 @router.get("/info")
 @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."""
     """Get comprehensive system information."""
 
 
     # Database stats
     # Database stats

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

@@ -11,8 +11,11 @@ import httpx
 from fastapi import APIRouter, BackgroundTasks, Depends
 from fastapi import APIRouter, BackgroundTasks, Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 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.config import APP_VERSION, GITHUB_REPO, settings
 from backend.app.core.database import get_db
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -153,7 +156,10 @@ def is_newer_version(latest: str, current: str) -> bool:
 
 
 @router.get("/version")
 @router.get("/version")
 async def 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 {
     return {
         "version": APP_VERSION,
         "version": APP_VERSION,
         "repo": GITHUB_REPO,
         "repo": GITHUB_REPO,
@@ -161,7 +167,10 @@ async def get_version():
 
 
 
 
 @router.get("/check")
 @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."""
     """Check GitHub for available updates."""
     global _update_status
     global _update_status
 
 
@@ -432,7 +441,10 @@ async def _perform_update():
 
 
 
 
 @router.post("/apply")
 @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)."""
     """Apply available update (git pull + rebuild)."""
     global _update_status
     global _update_status
 
 
@@ -473,6 +485,8 @@ async def apply_update(background_tasks: BackgroundTasks):
 
 
 
 
 @router.get("/status")
 @router.get("/status")
-async def get_update_status():
+async def get_update_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
     """Get current update status."""
     """Get current update status."""
     return _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);
       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);
+    });
+  });
 });
 });