VYPR
Critical severityNVD Advisory· Published Feb 4, 2026· Updated Feb 6, 2026

Bambuddy Uses Hardcoded Secret Key + Many API Endpoints do not Require Authentication

CVE-2026-25505

Description

Bambuddy is a self-hosted print archive and management system for Bambu Lab 3D printers. Prior to version 0.1.7, a hardcoded secret key used for signing JWTs is checked into source code and ManyAPI routes do not check authentication. This issue has been patched in version 0.1.7.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Bambuddy prior to 0.1.7 used a hardcoded JWT secret and lacked authentication on many API endpoints, allowing unauthorized access and potential control of 3D printers.

Vulnerability

Bambuddy, a self-hosted print archive and management system for Bambu Lab 3D printers, contained a critical security flaw in versions prior to 0.1.7. The backend used a hardcoded secret key ("bambuddy-secret-key-change-in-production") for signing JSON Web Tokens (JWTs), stored in backend/app/core/auth.py [2][3]. Additionally, many API endpoints did not enforce authentication, even when authentication was enabled [2].

Exploitation

An attacker could forge valid JWTs using the hardcoded secret, bypassing authentication entirely. Alternatively, they could directly access unprotected API endpoints without any token. A proof-of-concept in the security advisory enumerates over 200 GET endpoints across various routers (system, auth, users, printers, archives, etc.) that are accessible without authentication [3]. No prior access or credentials are required for exploitation.

Impact

Successful exploitation allows an attacker to gain unauthorized access to sensitive data and functionality, including printer controls, archives, settings, and user information. This could lead to manipulation of 3D printer operations, data exfiltration, or further compromise of the system. The vulnerability has a CVSS score of 9.8 (Critical) [4].

Mitigation

The issue is patched in Bambuddy version 0.1.7, which added authentication checks to over 200 API endpoints via RequirePermissionIfAuthEnabled() and enforced permission requirements [4]. Users running authentication-enabled instances are strongly advised to upgrade immediately to protect their systems.

AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
bambuddyPyPI
< 0.1.70.1.7

Affected products

2

Patches

2
a82f9278d2d5

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

https://github.com/maziggy/bambuddymaziggyFeb 3, 2026via ghsa
28 files changed · +948 119
  • backend/app/api/routes/ams_history.py+5 0 modified
    @@ -7,8 +7,11 @@
     from sqlalchemy import and_, func, select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.ams_history import AMSSensorHistory
    +from backend.app.models.user import User
     
     router = APIRouter(prefix="/ams-history", tags=["ams-history"])
     
    @@ -38,6 +41,7 @@ async def get_ams_history(
         ams_id: int,
         hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
     ):
         """Get AMS sensor history for a specific printer and AMS unit."""
         since = datetime.now() - timedelta(hours=hours)
    @@ -101,6 +105,7 @@ async def delete_old_history(
         printer_id: int,
         days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
     ):
         """Delete old AMS history data for a printer."""
         cutoff = datetime.now() - timedelta(days=days)
    
  • backend/app/api/routes/api_keys.py+11 2 modified
    @@ -4,9 +4,11 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    -from backend.app.core.auth import generate_api_key
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.api_key import APIKey
    +from backend.app.models.user import User
     from backend.app.schemas.api_key import (
         APIKeyCreate,
         APIKeyCreateResponse,
    @@ -20,7 +22,10 @@
     
     
     @router.get("/", response_model=list[APIKeyResponse])
    -async def list_api_keys(db: AsyncSession = Depends(get_db)):
    +async def list_api_keys(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
    +):
         """List all API keys (without full key values)."""
         result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
         return list(result.scalars().all())
    @@ -30,6 +35,7 @@ async def list_api_keys(db: AsyncSession = Depends(get_db)):
     async def create_api_key(
         data: APIKeyCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
     ):
         """Create a new API key.
     
    @@ -74,6 +80,7 @@ async def create_api_key(
     async def get_api_key(
         key_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
     ):
         """Get an API key by ID."""
         result = await db.execute(select(APIKey).where(APIKey.id == key_id))
    @@ -90,6 +97,7 @@ async def update_api_key(
         key_id: int,
         data: APIKeyUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
     ):
         """Update an API key."""
         result = await db.execute(select(APIKey).where(APIKey.id == key_id))
    @@ -124,6 +132,7 @@ async def update_api_key(
     async def delete_api_key(
         key_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
     ):
         """Delete (revoke) an API key."""
         result = await db.execute(select(APIKey).where(APIKey.id == key_id))
    
  • backend/app/api/routes/archives.py+137 26 modified
    @@ -8,7 +8,10 @@
     from sqlalchemy import func, select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    -from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
    +from backend.app.core.auth import (
    +    RequirePermissionIfAuthEnabled,
    +    require_ownership_permission,
    +)
     from backend.app.core.config import settings
     from backend.app.core.database import get_db
     from backend.app.core.permissions import Permission
    @@ -118,6 +121,7 @@ async def list_archives(
         limit: int = 50,
         offset: int = 0,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """List archived prints."""
         service = ArchiveService(db)
    @@ -148,6 +152,7 @@ async def search_archives(
         limit: int = 50,
         offset: int = 0,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Full-text search across archives.
     
    @@ -229,7 +234,10 @@ async def search_archives(
     
     
     @router.post("/search/rebuild-index")
    -async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
    +async def rebuild_search_index(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
    +):
         """Rebuild the full-text search index from existing archives.
     
         Use this if search results seem incomplete or incorrect.
    @@ -267,6 +275,7 @@ async def analyze_failures(
         printer_id: int | None = None,
         project_id: int | None = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Analyze failure patterns across prints.
     
    @@ -291,6 +300,7 @@ async def analyze_failures(
     async def compare_archives(
         archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Compare multiple archives side by side.
     
    @@ -331,6 +341,7 @@ async def export_archives(
         date_to: str | None = Query(None, description="End date (ISO format)"),
         search: str | None = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Export archives to CSV or Excel format.
     
    @@ -393,6 +404,7 @@ async def export_stats(
         printer_id: int | None = None,
         project_id: int | None = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
     ):
         """Export statistics summary to CSV or Excel format."""
         from fastapi.responses import StreamingResponse
    @@ -421,7 +433,10 @@ async def export_stats(
     
     
     @router.get("/stats", response_model=ArchiveStats)
    -async def get_archive_stats(db: AsyncSession = Depends(get_db)):
    +async def get_archive_stats(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
    +):
         """Get statistics across all archives."""
         # Total counts
         total_result = await db.execute(select(func.count(PrintArchive.id)))
    @@ -574,7 +589,10 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/tags")
    -async def get_all_tags(db: AsyncSession = Depends(get_db)):
    +async def get_all_tags(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
    +):
         """List all unique tags with usage counts.
     
         Returns a list of tags sorted by count (descending), then by name.
    @@ -604,6 +622,7 @@ async def rename_tag(
         tag_name: str,
         request: Request,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     ):
         """Rename a tag across all archives.
     
    @@ -646,7 +665,11 @@ async def rename_tag(
     
     
     @router.delete("/tags/{tag_name}")
    -async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
    +async def delete_tag(
    +    tag_name: str,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
    +):
         """Delete a tag from all archives.
     
         Returns the count of affected archives.
    @@ -671,7 +694,11 @@ async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/{archive_id}", response_model=ArchiveResponse)
    -async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_archive(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
    +):
         """Get a specific archive."""
         service = ArchiveService(db)
         archive = await service.get_archive(archive_id)
    @@ -694,6 +721,7 @@ async def find_similar_archives(
         archive_id: int,
         limit: int = 10,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Find archives with similar settings for comparison.
     
    @@ -762,6 +790,7 @@ async def update_archive(
     async def toggle_favorite(
         archive_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
     ):
         """Toggle favorite status for an archive."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    @@ -776,7 +805,11 @@ async def toggle_favorite(
     
     
     @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
    -async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
    +async def rescan_archive(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
    +):
         """Rescan the 3MF file and update metadata."""
         from backend.app.api.routes.settings import get_setting
         from backend.app.services.archive import ThreeMFParser
    @@ -835,7 +868,10 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/recalculate-costs")
    -async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
    +async def recalculate_all_costs(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
    +):
         """Recalculate costs for all archives based on filament usage and prices."""
         from backend.app.api.routes.settings import get_setting
     
    @@ -865,7 +901,10 @@ async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/rescan-all")
    -async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
    +async def rescan_all_archives(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
    +):
         """Rescan all archives and update their metadata."""
         from backend.app.services.archive import ThreeMFParser
     
    @@ -912,7 +951,11 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/{archive_id}/duplicates")
    -async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_archive_duplicates(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
    +):
         """Get duplicates for a specific archive."""
         service = ArchiveService(db)
         archive = await service.get_archive(archive_id)
    @@ -930,7 +973,10 @@ async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get
     
     
     @router.post("/backfill-hashes")
    -async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
    +async def backfill_content_hashes(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
    +):
         """Compute and store content hashes for all archives missing them."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))
         archives = list(result.scalars().all())
    @@ -991,6 +1037,7 @@ async def download_archive(
         archive_id: int,
         inline: bool = False,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Download the 3MF file."""
         service = ArchiveService(db)
    @@ -1018,6 +1065,7 @@ async def download_archive_with_filename(
         archive_id: int,
         filename: str,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
         service = ArchiveService(db)
    @@ -1037,8 +1085,14 @@ async def download_archive_with_filename(
     
     
     @router.get("/{archive_id}/thumbnail")
    -async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
    -    """Get the thumbnail image."""
    +async def get_thumbnail(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +):
    +    """Get the thumbnail image.
    +
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
    +    """
         service = ArchiveService(db)
         archive = await service.get_archive(archive_id)
         if not archive or not archive.thumbnail_path:
    @@ -1062,8 +1116,14 @@ async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/{archive_id}/timelapse")
    -async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
    -    """Get the timelapse video."""
    +async def get_timelapse(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +):
    +    """Get the timelapse video.
    +
    +    Note: Unauthenticated - loaded via <video> tags which can't send auth headers.
    +    """
         service = ArchiveService(db)
         archive = await service.get_archive(archive_id)
         if not archive or not archive.timelapse_path:
    @@ -1091,6 +1151,7 @@ async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
     async def scan_timelapse(
         archive_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     ):
         """Scan printer for timelapse matching this archive and attach it."""
         from backend.app.models.printer import Printer
    @@ -1317,6 +1378,7 @@ async def select_timelapse(
         archive_id: int,
         filename: str = Query(..., description="Timelapse filename to attach"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     ):
         """Manually select a timelapse from the printer to attach."""
         from backend.app.models.printer import Printer
    @@ -1401,6 +1463,7 @@ async def upload_timelapse(
         archive_id: int,
         file: UploadFile = File(...),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     ):
         """Manually upload a timelapse video to an archive."""
         service = ArchiveService(db)
    @@ -1421,7 +1484,11 @@ async def upload_timelapse(
     
     
     @router.get("/{archive_id}/timelapse/info")
    -async def get_timelapse_info(archive_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_timelapse_info(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
    +):
         """Get timelapse video metadata for editor."""
         from backend.app.schemas.timelapse import TimelapseInfoResponse
         from backend.app.services.timelapse_processor import TimelapseProcessor
    @@ -1450,6 +1517,7 @@ async def get_timelapse_thumbnails(
         count: int = Query(10, ge=1, le=30),
         width: int = Query(160, ge=80, le=320),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Generate timeline thumbnail frames for visual scrubbing."""
         import base64
    @@ -1489,6 +1557,7 @@ async def process_timelapse(
         output_filename: str = Form(None),
         audio: UploadFile = File(None),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     ):
         """Process timelapse with trim, speed, and optional audio overlay."""
         import shutil
    @@ -1592,6 +1661,7 @@ async def upload_photo(
         archive_id: int,
         file: UploadFile = File(...),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
     ):
         """Upload a photo of the printed result."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    @@ -1636,7 +1706,10 @@ async def get_photo(
         filename: str,
         db: AsyncSession = Depends(get_db),
     ):
    -    """Get a specific photo."""
    +    """Get a specific photo.
    +
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
    +    """
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
         archive = result.scalar_one_or_none()
         if not archive:
    @@ -1666,6 +1739,7 @@ async def delete_photo(
         archive_id: int,
         filename: str,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
     ):
         """Delete a photo."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    @@ -1703,7 +1777,10 @@ async def get_qrcode(
         size: int = 200,
         db: AsyncSession = Depends(get_db),
     ):
    -    """Generate a QR code that links to this archive."""
    +    """Generate a QR code that links to this archive.
    +
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
    +    """
         try:
             import qrcode
             from PIL import Image as PILImage
    @@ -1751,7 +1828,11 @@ async def get_qrcode(
     
     
     @router.get("/{archive_id}/capabilities")
    -async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_archive_capabilities(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
    +):
         """Check what viewing capabilities are available for this 3MF file."""
         import json
         import xml.etree.ElementTree as ET
    @@ -1968,7 +2049,11 @@ def extract_3mf_info(zf_path: Path) -> tuple[bool, list[str], dict]:
     
     
     @router.get("/{archive_id}/gcode")
    -async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_gcode(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
    +):
         """Extract and return G-code from the 3MF file."""
         service = ArchiveService(db)
         archive = await service.get_archive(archive_id)
    @@ -2001,11 +2086,16 @@ async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/{archive_id}/plate-preview")
    -async def get_plate_preview(archive_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_plate_preview(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +):
         """Get the plate preview image from the 3MF file.
     
         Returns the slicer-generated plate thumbnail which shows the model
         with correct colors and positioning.
    +
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
         """
         service = ArchiveService(db)
         archive = await service.get_archive(archive_id)
    @@ -2068,7 +2158,7 @@ async def upload_archive(
         file: UploadFile = File(...),
         printer_id: int | None = None,
         db: AsyncSession = Depends(get_db),
    -    current_user: User | None = Depends(require_auth_if_enabled),
    +    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
     ):
         """Manually upload a 3MF file to archive."""
         if not file.filename or not file.filename.endswith(".3mf"):
    @@ -2103,7 +2193,7 @@ async def upload_archives_bulk(
         files: list[UploadFile] = File(...),
         printer_id: int | None = None,
         db: AsyncSession = Depends(get_db),
    -    current_user: User | None = Depends(require_auth_if_enabled),
    +    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
     ):
         """Bulk upload multiple 3MF files to archive."""
         results = []
    @@ -2157,6 +2247,7 @@ async def upload_archives_bulk(
     async def get_archive_plates(
         archive_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Get available plates from a multi-plate 3MF archive.
     
    @@ -2337,7 +2428,10 @@ async def get_plate_thumbnail(
         plate_index: int,
         db: AsyncSession = Depends(get_db),
     ):
    -    """Get the thumbnail image for a specific plate."""
    +    """Get the thumbnail image for a specific plate.
    +
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
    +    """
         service = ArchiveService(db)
         archive = await service.get_archive(archive_id)
         if not archive:
    @@ -2364,6 +2458,7 @@ async def get_filament_requirements(
         archive_id: int,
         plate_id: int | None = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Get filament requirements from the archived 3MF file.
     
    @@ -2659,7 +2754,11 @@ async def reprint_archive(
     
     
     @router.get("/{archive_id}/project-page")
    -async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_project_page(
    +    archive_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
    +):
         """Get the project page data from the 3MF file."""
         from backend.app.schemas.archive import ProjectPageResponse
         from backend.app.services.archive import ProjectPageParser
    @@ -2684,6 +2783,7 @@ async def update_project_page(
         archive_id: int,
         update_data: dict,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
     ):
         """Update project page metadata in the 3MF file."""
         from backend.app.services.archive import ProjectPageParser
    @@ -2714,7 +2814,10 @@ async def get_project_image(
         image_path: str,
         db: AsyncSession = Depends(get_db),
     ):
    -    """Get an image from the 3MF project page."""
    +    """Get an image from the 3MF project page.
    +
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
    +    """
         from backend.app.services.archive import ProjectPageParser
     
         service = ArchiveService(db)
    @@ -2750,6 +2853,7 @@ async def upload_source_3mf(
         archive_id: int,
         file: UploadFile = File(...),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
     ):
         """Upload the original source 3MF project file for an archive."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    @@ -2796,6 +2900,7 @@ async def upload_source_3mf(
     async def download_source_3mf(
         archive_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Download the source 3MF project file."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    @@ -2825,6 +2930,7 @@ async def download_source_3mf_for_slicer(
         archive_id: int,
         filename: str,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    @@ -2851,6 +2957,7 @@ async def upload_source_3mf_by_name(
         file: UploadFile = File(...),
         print_name: str = Query(None, description="Match archive by print name"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     ):
         """Upload source 3MF and match to archive by print name.
     
    @@ -2937,6 +3044,7 @@ async def upload_source_3mf_by_name(
     async def delete_source_3mf(
         archive_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
     ):
         """Delete the source 3MF project file from an archive."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    @@ -2969,6 +3077,7 @@ async def upload_f3d(
         archive_id: int,
         file: UploadFile = File(...),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
     ):
         """Upload a Fusion 360 design file for an archive."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    @@ -3015,6 +3124,7 @@ async def upload_f3d(
     async def download_f3d(
         archive_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     ):
         """Download the Fusion 360 design file."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    @@ -3043,6 +3153,7 @@ async def download_f3d(
     async def delete_f3d(
         archive_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
     ):
         """Delete the Fusion 360 design file from an archive."""
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
    
  • backend/app/api/routes/camera.py+28 3 modified
    @@ -9,8 +9,11 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.printer import Printer
    +from backend.app.models.user import User
     from backend.app.services.camera import (
         capture_camera_frame,
         generate_chamber_image_stream,
    @@ -353,6 +356,8 @@ async def camera_stream(
         This endpoint returns a multipart MJPEG stream that can be used directly
         in an <img> tag or video player.
     
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
    +
         Uses external camera if configured, otherwise uses built-in camera:
         - External: MJPEG, RTSP, or HTTP snapshot
         - A1/P1: Chamber image protocol (port 6000)
    @@ -476,7 +481,10 @@ async def stream_with_disconnect_check():
     
     
     @router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
    -async def stop_camera_stream(printer_id: int):
    +async def stop_camera_stream(
    +    printer_id: int,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
    +):
         """Stop all active camera streams for a printer.
     
         This can be called by the frontend when the camera window is closed.
    @@ -527,6 +535,8 @@ async def camera_snapshot(
         """Capture a single frame from the printer camera.
     
         Returns a JPEG image.
    +
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
         """
         import tempfile
         from pathlib import Path
    @@ -574,6 +584,7 @@ async def camera_snapshot(
     async def test_camera(
         printer_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     ):
         """Test camera connection for a printer.
     
    @@ -591,7 +602,10 @@ async def test_camera(
     
     
     @router.get("/{printer_id}/camera/status")
    -async def camera_status(printer_id: int):
    +async def camera_status(
    +    printer_id: int,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
    +):
         """Get the status of an active camera stream.
     
         Returns whether a stream is active and when the last frame was received.
    @@ -658,6 +672,7 @@ async def test_external_camera(
         url: str,
         camera_type: str,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     ):
         """Test external camera connection.
     
    @@ -684,6 +699,7 @@ async def check_plate_empty(
         use_external: bool = False,
         include_debug_image: bool = False,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     ):
         """Check if the build plate is empty using camera vision.
     
    @@ -791,6 +807,7 @@ async def calibrate_plate_detection(
         label: str | None = None,
         use_external: bool = False,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     ):
         """Calibrate plate detection by capturing a reference image of the empty plate.
     
    @@ -854,6 +871,7 @@ async def delete_plate_calibration(
         printer_id: int,
         plate_type: str | None = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     ):
         """Delete the plate detection calibration for a printer and plate type.
     
    @@ -894,6 +912,7 @@ async def get_plate_detection_status(
         printer_id: int,
         plate_type: str | None = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     ):
         """Check plate detection status for a printer and plate type.
     
    @@ -937,6 +956,7 @@ async def get_plate_detection_status(
     async def get_plate_references(
         printer_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     ):
         """Get all calibration references for a printer with metadata.
     
    @@ -971,7 +991,10 @@ async def get_reference_thumbnail(
         index: int,
         db: AsyncSession = Depends(get_db),
     ):
    -    """Get thumbnail image for a calibration reference."""
    +    """Get thumbnail image for a calibration reference.
    +
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
    +    """
         from fastapi.responses import Response
     
         from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
    @@ -997,6 +1020,7 @@ async def update_reference_label(
         index: int,
         label: str,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     ):
         """Update the label for a calibration reference."""
         from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
    @@ -1021,6 +1045,7 @@ async def delete_reference(
         printer_id: int,
         index: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     ):
         """Delete a specific calibration reference."""
         from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
    
  • backend/app/api/routes/cloud.py+63 13 modified
    @@ -13,8 +13,11 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.settings import Settings
    +from backend.app.models.user import User
     from backend.app.schemas.cloud import (
         CloudAuthStatus,
         CloudDevice,
    @@ -74,7 +77,10 @@ async def clear_token(db: AsyncSession) -> None:
     
     
     @router.get("/status", response_model=CloudAuthStatus)
    -async def get_auth_status(db: AsyncSession = Depends(get_db)):
    +async def get_auth_status(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
    +):
         """Get current cloud authentication status."""
         token, email = await get_stored_token(db)
         cloud = get_cloud_service()
    @@ -89,7 +95,11 @@ async def get_auth_status(db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/login", response_model=CloudLoginResponse)
    -async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
    +async def login(
    +    request: CloudLoginRequest,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
    +):
         """
         Initiate login to Bambu Cloud.
     
    @@ -126,7 +136,11 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/verify", response_model=CloudLoginResponse)
    -async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
    +async def verify_code(
    +    request: CloudVerifyRequest,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
    +):
         """
         Complete login with verification code (email or TOTP).
     
    @@ -162,7 +176,11 @@ async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(ge
     
     
     @router.post("/token", response_model=CloudAuthStatus)
    -async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_db)):
    +async def set_token(
    +    request: CloudTokenRequest,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
    +):
         """
         Set access token directly.
     
    @@ -182,7 +200,10 @@ async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_d
     
     
     @router.post("/logout")
    -async def logout(db: AsyncSession = Depends(get_db)):
    +async def logout(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
    +):
         """Log out of Bambu Cloud."""
         cloud = get_cloud_service()
         cloud.logout()
    @@ -194,6 +215,7 @@ async def logout(db: AsyncSession = Depends(get_db)):
     async def get_slicer_settings(
         version: str = "02.04.00.70",
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     ):
         """
         Get all slicer settings (filament, printer, process presets).
    @@ -250,7 +272,11 @@ async def get_slicer_settings(
     
     
     @router.get("/settings/{setting_id}")
    -async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)):
    +async def get_setting_detail(
    +    setting_id: str,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
    +):
         """
         Get detailed information for a specific setting/preset.
     
    @@ -311,7 +337,11 @@ def _filament_id_to_setting_id(filament_id: str) -> str:
     
     
     @router.post("/filament-info")
    -async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
    +async def get_filament_info(
    +    setting_ids: list[str] = Body(...),
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
    +):
         """
         Get filament preset info (name and K value) for multiple setting IDs.
     
    @@ -385,7 +415,10 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
     
     
     @router.get("/devices", response_model=list[CloudDevice])
    -async def get_devices(db: AsyncSession = Depends(get_db)):
    +async def get_devices(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
    +):
         """
         Get list of bound printer devices.
     
    @@ -423,7 +456,10 @@ async def get_devices(db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
    -async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
    +async def get_firmware_updates(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
    +):
         """
         Check for firmware updates for all bound devices.
     
    @@ -499,7 +535,11 @@ async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/settings")
    -async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
    +async def create_setting(
    +    request: SlicerSettingCreate,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
    +):
         """
         Create a new slicer preset/setting.
     
    @@ -539,6 +579,7 @@ async def update_setting(
         setting_id: str,
         request: SlicerSettingUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     ):
         """
         Update an existing slicer preset/setting.
    @@ -570,7 +611,11 @@ async def update_setting(
     
     
     @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
    -async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)):
    +async def delete_setting(
    +    setting_id: str,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
    +):
         """
         Delete a slicer preset/setting.
     
    @@ -635,7 +680,10 @@ def _load_fields(preset_type: str) -> dict:
     
     
     @router.get("/fields/{preset_type}")
    -async def get_preset_fields(preset_type: Literal["filament", "print", "process", "printer"]):
    +async def get_preset_fields(
    +    preset_type: Literal["filament", "print", "process", "printer"],
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
    +):
         """
         Get field definitions for a preset type.
     
    @@ -654,7 +702,9 @@ async def get_preset_fields(preset_type: Literal["filament", "print", "process",
     
     
     @router.get("/fields")
    -async def get_all_preset_fields():
    +async def get_all_preset_fields(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
    +):
         """
         Get all field definitions for all preset types.
     
    
  • backend/app/api/routes/discovery.py+29 8 modified
    @@ -10,6 +10,9 @@
     from fastapi import APIRouter
     from pydantic import BaseModel
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
    +from backend.app.core.permissions import Permission
    +from backend.app.models.user import User
     from backend.app.services.discovery import (
         discovery_service,
         is_running_in_docker,
    @@ -60,7 +63,9 @@ class DiscoveredPrinterResponse(BaseModel):
     
     
     @router.get("/info", response_model=DiscoveryInfo)
    -async def get_discovery_info():
    +async def get_discovery_info(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
    +):
         """Get discovery environment info (Docker detection, etc.)."""
         return DiscoveryInfo(
             is_docker=is_running_in_docker(),
    @@ -70,13 +75,18 @@ async def get_discovery_info():
     
     
     @router.get("/status", response_model=DiscoveryStatus)
    -async def get_discovery_status():
    +async def get_discovery_status(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
    +):
         """Get the current SSDP discovery status."""
         return DiscoveryStatus(running=discovery_service.is_running)
     
     
     @router.post("/start", response_model=DiscoveryStatus)
    -async def start_discovery(duration: float = 10.0):
    +async def start_discovery(
    +    duration: float = 10.0,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
    +):
         """Start SSDP printer discovery.
     
         Args:
    @@ -87,14 +97,18 @@ async def start_discovery(duration: float = 10.0):
     
     
     @router.post("/stop", response_model=DiscoveryStatus)
    -async def stop_discovery():
    +async def stop_discovery(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
    +):
         """Stop SSDP printer discovery."""
         await discovery_service.stop()
         return DiscoveryStatus(running=discovery_service.is_running)
     
     
     @router.get("/printers", response_model=list[DiscoveredPrinterResponse])
    -async def get_discovered_printers():
    +async def get_discovered_printers(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
    +):
         """Get list of discovered printers (from both SSDP and subnet scan)."""
         # Combine results from both discovery methods
         printers = {}
    @@ -124,7 +138,10 @@ async def get_discovered_printers():
     
     
     @router.post("/scan", response_model=SubnetScanStatus)
    -async def start_subnet_scan(request: SubnetScanRequest):
    +async def start_subnet_scan(
    +    request: SubnetScanRequest,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
    +):
         """Start a subnet scan for Bambu printers.
     
         Use this when running in Docker where SSDP multicast doesn't work.
    @@ -147,7 +164,9 @@ async def start_subnet_scan(request: SubnetScanRequest):
     
     
     @router.get("/scan/status", response_model=SubnetScanStatus)
    -async def get_scan_status():
    +async def get_scan_status(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
    +):
         """Get the current subnet scan status."""
         scanned, total = subnet_scanner.progress
         return SubnetScanStatus(
    @@ -158,7 +177,9 @@ async def get_scan_status():
     
     
     @router.post("/scan/stop", response_model=SubnetScanStatus)
    -async def stop_subnet_scan():
    +async def stop_subnet_scan(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
    +):
         """Stop the current subnet scan."""
         subnet_scanner.stop()
         scanned, total = subnet_scanner.progress
    
  • backend/app/api/routes/external_links.py+18 2 modified
    @@ -9,9 +9,12 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.config import settings as app_settings
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.external_link import ExternalLink
    +from backend.app.models.user import User
     from backend.app.schemas.external_link import (
         ExternalLinkCreate,
         ExternalLinkReorder,
    @@ -29,7 +32,10 @@
     
     
     @router.get("/", response_model=list[ExternalLinkResponse])
    -async def list_external_links(db: AsyncSession = Depends(get_db)):
    +async def list_external_links(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
    +):
         """List all external links ordered by sort_order."""
         result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
         links = result.scalars().all()
    @@ -40,6 +46,7 @@ async def list_external_links(db: AsyncSession = Depends(get_db)):
     async def create_external_link(
         link_data: ExternalLinkCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
     ):
         """Create a new external link."""
         # Get the highest sort_order to place new link at end
    @@ -67,6 +74,7 @@ async def create_external_link(
     async def get_external_link(
         link_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
     ):
         """Get a specific external link."""
         result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
    @@ -83,6 +91,7 @@ async def update_external_link(
         link_id: int,
         update_data: ExternalLinkUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
     ):
         """Update an external link."""
         result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
    @@ -108,6 +117,7 @@ async def update_external_link(
     async def delete_external_link(
         link_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
     ):
         """Delete an external link."""
         result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
    @@ -129,6 +139,7 @@ async def delete_external_link(
     async def reorder_external_links(
         reorder_data: ExternalLinkReorder,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
     ):
         """Update the sort order of external links."""
         # Update sort_order for each link based on position in the list
    @@ -154,6 +165,7 @@ async def upload_icon(
         link_id: int,
         file: UploadFile = File(...),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
     ):
         """Upload a custom icon for an external link."""
         result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
    @@ -202,6 +214,7 @@ async def upload_icon(
     async def delete_icon(
         link_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
     ):
         """Delete the custom icon for an external link."""
         result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
    @@ -227,7 +240,10 @@ async def get_icon(
         link_id: int,
         db: AsyncSession = Depends(get_db),
     ):
    -    """Get the custom icon for an external link."""
    +    """Get the custom icon for an external link.
    +
    +    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
    +    """
         result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
         link = result.scalar_one_or_none()
     
    
  • backend/app/api/routes/filaments.py+25 4 modified
    @@ -2,8 +2,11 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.filament import Filament
    +from backend.app.models.user import User
     from backend.app.schemas.filament import (
         FilamentCostCalculation,
         FilamentCreate,
    @@ -15,7 +18,10 @@
     
     
     @router.get("/", response_model=list[FilamentResponse])
    -async def list_filaments(db: AsyncSession = Depends(get_db)):
    +async def list_filaments(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
    +):
         """List all filaments."""
         result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))
         return list(result.scalars().all())
    @@ -25,6 +31,7 @@ async def list_filaments(db: AsyncSession = Depends(get_db)):
     async def create_filament(
         filament_data: FilamentCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
     ):
         """Create a new filament entry."""
         filament = Filament(**filament_data.model_dump())
    @@ -35,7 +42,11 @@ async def create_filament(
     
     
     @router.get("/{filament_id}", response_model=FilamentResponse)
    -async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_filament(
    +    filament_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
    +):
         """Get a specific filament."""
         result = await db.execute(select(Filament).where(Filament.id == filament_id))
         filament = result.scalar_one_or_none()
    @@ -49,6 +60,7 @@ async def update_filament(
         filament_id: int,
         filament_data: FilamentUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     ):
         """Update a filament."""
         result = await db.execute(select(Filament).where(Filament.id == filament_id))
    @@ -65,7 +77,11 @@ async def update_filament(
     
     
     @router.delete("/{filament_id}")
    -async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
    +async def delete_filament(
    +    filament_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),
    +):
         """Delete a filament."""
         result = await db.execute(select(Filament).where(Filament.id == filament_id))
         filament = result.scalar_one_or_none()
    @@ -82,6 +98,7 @@ async def calculate_cost(
         filament_id: int,
         weight_grams: float,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     ):
         """Calculate the cost for a given weight of filament."""
         result = await db.execute(select(Filament).where(Filament.id == filament_id))
    @@ -104,14 +121,18 @@ async def calculate_cost(
     async def get_filaments_by_type(
         filament_type: str,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     ):
         """Get all filaments of a specific type."""
         result = await db.execute(select(Filament).where(Filament.type.ilike(f"%{filament_type}%")).order_by(Filament.name))
         return list(result.scalars().all())
     
     
     @router.post("/seed-defaults")
    -async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
    +async def seed_default_filaments(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
    +):
         """Seed the database with common filament types."""
         defaults = [
             {
    
  • backend/app/api/routes/firmware.py+14 2 modified
    @@ -12,8 +12,11 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.printer import Printer
    +from backend.app.models.user import User
     from backend.app.services.firmware_check import get_firmware_service
     from backend.app.services.firmware_update import (
         FirmwareUploadStatus,
    @@ -59,6 +62,7 @@ class LatestFirmwareInfo(BaseModel):
     @router.get("/updates", response_model=FirmwareUpdatesResponse)
     async def check_firmware_updates(
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
     ):
         """
         Check for firmware updates for all connected printers.
    @@ -112,6 +116,7 @@ async def check_firmware_updates(
     async def check_printer_firmware(
         printer_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
     ):
         """
         Check for firmware update for a specific printer.
    @@ -148,7 +153,9 @@ async def check_printer_firmware(
     
     
     @router.get("/latest", response_model=list[LatestFirmwareInfo])
    -async def get_all_latest_firmware():
    +async def get_all_latest_firmware(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
    +):
         """
         Get the latest firmware versions for all Bambu Lab printer models.
     
    @@ -211,6 +218,7 @@ class FirmwareUploadStartResponse(BaseModel):
     async def prepare_firmware_upload(
         printer_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
     ):
         """
         Check prerequisites for uploading firmware to a printer.
    @@ -232,6 +240,7 @@ async def prepare_firmware_upload(
     async def start_firmware_upload(
         printer_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
     ):
         """
         Start uploading firmware to a printer's SD card.
    @@ -279,7 +288,10 @@ async def start_firmware_upload(
     
     
     @router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
    -async def get_firmware_upload_status(printer_id: int):
    +async def get_firmware_upload_status(
    +    printer_id: int,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
    +):
         """
         Get the current status of a firmware upload operation.
     
    
  • backend/app/api/routes/github_backup.py+28 5 modified
    @@ -6,8 +6,11 @@
     from sqlalchemy import delete, desc, select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
    +from backend.app.models.user import User
     from backend.app.schemas.github_backup import (
         GitHubBackupConfigCreate,
         GitHubBackupConfigResponse,
    @@ -48,7 +51,10 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
     
     
     @router.get("/config", response_model=GitHubBackupConfigResponse | None)
    -async def get_config(db: AsyncSession = Depends(get_db)):
    +async def get_config(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
    +):
         """Get the current GitHub backup configuration."""
         result = await db.execute(select(GitHubBackupConfig).limit(1))
         config = result.scalar_one_or_none()
    @@ -63,6 +69,7 @@ async def get_config(db: AsyncSession = Depends(get_db)):
     async def save_config(
         config_data: GitHubBackupConfigCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     ):
         """Create or update GitHub backup configuration.
     
    @@ -121,6 +128,7 @@ async def save_config(
     async def update_config(
         update_data: GitHubBackupConfigUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     ):
         """Partially update GitHub backup configuration."""
         result = await db.execute(select(GitHubBackupConfig).limit(1))
    @@ -153,7 +161,10 @@ async def update_config(
     
     
     @router.delete("/config")
    -async def delete_config(db: AsyncSession = Depends(get_db)):
    +async def delete_config(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
    +):
         """Delete the GitHub backup configuration and all logs."""
         result = await db.execute(select(GitHubBackupConfig).limit(1))
         config = result.scalar_one_or_none()
    @@ -173,14 +184,18 @@ async def delete_config(db: AsyncSession = Depends(get_db)):
     async def test_connection(
         repo_url: str = Query(..., description="GitHub repository URL"),
         token: str = Query(..., description="Personal Access Token"),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     ):
         """Test GitHub connection with provided credentials."""
         result = await github_backup_service.test_connection(repo_url, token)
         return GitHubTestConnectionResponse(**result)
     
     
     @router.post("/test-stored", response_model=GitHubTestConnectionResponse)
    -async def test_stored_connection(db: AsyncSession = Depends(get_db)):
    +async def test_stored_connection(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
    +):
         """Test GitHub connection using stored configuration."""
         result = await db.execute(select(GitHubBackupConfig).limit(1))
         config = result.scalar_one_or_none()
    @@ -196,7 +211,10 @@ async def test_stored_connection(db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/run", response_model=GitHubBackupTriggerResponse)
    -async def trigger_backup(db: AsyncSession = Depends(get_db)):
    +async def trigger_backup(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
    +):
         """Manually trigger a backup."""
         result = await db.execute(select(GitHubBackupConfig).limit(1))
         config = result.scalar_one_or_none()
    @@ -213,7 +231,10 @@ async def trigger_backup(db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/status", response_model=GitHubBackupStatus)
    -async def get_status(db: AsyncSession = Depends(get_db)):
    +async def get_status(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
    +):
         """Get current backup status."""
         result = await db.execute(select(GitHubBackupConfig).limit(1))
         config = result.scalar_one_or_none()
    @@ -245,6 +266,7 @@ async def get_logs(
         limit: int = Query(default=50, ge=1, le=200),
         offset: int = Query(default=0, ge=0),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     ):
         """Get backup logs."""
         result = await db.execute(select(GitHubBackupConfig).limit(1))
    @@ -282,6 +304,7 @@ async def get_logs(
     async def clear_logs(
         keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
     ):
         """Clear backup logs, optionally keeping the most recent entries."""
         result = await db.execute(select(GitHubBackupConfig).limit(1))
    
  • backend/app/api/routes/kprofiles.py+10 0 modified
    @@ -7,9 +7,12 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
     from backend.app.models.printer import Printer
    +from backend.app.models.user import User
     from backend.app.schemas.kprofile import (
         KProfile,
         KProfileCreate,
    @@ -30,6 +33,7 @@ async def get_kprofiles(
         printer_id: int,
         nozzle_diameter: str = "0.4",
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
     ):
         """Get K-profiles from a printer.
     
    @@ -78,6 +82,7 @@ async def set_kprofile(
         printer_id: int,
         profile: KProfileCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
     ):
         """Create or update a K-profile on the printer.
     
    @@ -178,6 +183,7 @@ async def set_kprofiles_batch(
         printer_id: int,
         profiles: list[KProfileCreate],
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
     ):
         """Create multiple K-profiles in a single command (for dual-nozzle).
     
    @@ -236,6 +242,7 @@ async def delete_kprofile(
         printer_id: int,
         profile: KProfileDelete,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
     ):
         """Delete a K-profile from the printer.
     
    @@ -278,6 +285,7 @@ async def delete_kprofile(
     async def get_kprofile_notes(
         printer_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
     ):
         """Get all K-profile notes for a printer.
     
    @@ -305,6 +313,7 @@ async def set_kprofile_note(
         printer_id: int,
         note_data: KProfileNote,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
     ):
         """Set or update a note for a K-profile.
     
    @@ -353,6 +362,7 @@ async def delete_kprofile_note(
         printer_id: int,
         setting_id: str,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
     ):
         """Delete a note for a K-profile.
     
    
  • backend/app/api/routes/maintenance.py+25 3 modified
    @@ -8,9 +8,12 @@
     from sqlalchemy.ext.asyncio import AsyncSession
     from sqlalchemy.orm import selectinload
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
     from backend.app.models.printer import Printer
    +from backend.app.models.user import User
     from backend.app.schemas.maintenance import (
         MaintenanceHistoryResponse,
         MaintenanceStatus,
    @@ -114,7 +117,10 @@ async def ensure_default_types(db: AsyncSession) -> None:
     
     
     @router.get("/types", response_model=list[MaintenanceTypeResponse])
    -async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
    +async def get_maintenance_types(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
    +):
         """Get all maintenance types."""
         await ensure_default_types(db)
         result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
    @@ -125,6 +131,7 @@ async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
     async def create_maintenance_type(
         data: MaintenanceTypeCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
     ):
         """Create a custom maintenance type."""
         new_type = MaintenanceType(
    @@ -146,6 +153,7 @@ async def update_maintenance_type(
         type_id: int,
         data: MaintenanceTypeUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     ):
         """Update a maintenance type."""
         result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
    @@ -166,6 +174,7 @@ async def update_maintenance_type(
     async def delete_maintenance_type(
         type_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
     ):
         """Delete a custom maintenance type."""
         result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
    @@ -331,13 +340,17 @@ async def _get_printer_maintenance_internal(
     async def get_printer_maintenance(
         printer_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     ):
         """Get maintenance overview for a specific printer."""
         return await _get_printer_maintenance_internal(printer_id, db, commit=True)
     
     
     @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
    -async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
    +async def get_all_maintenance_overview(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
    +):
         """Get maintenance overview for all active printers."""
         await ensure_default_types(db)
     
    @@ -361,6 +374,7 @@ async def update_printer_maintenance(
         item_id: int,
         data: PrinterMaintenanceUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     ):
         """Update a printer maintenance item (e.g., custom interval, enabled)."""
         result = await db.execute(
    @@ -386,6 +400,7 @@ async def assign_maintenance_type(
         printer_id: int,
         type_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
     ):
         """Assign a maintenance type to a specific printer (for custom types)."""
         # Verify printer exists
    @@ -438,6 +453,7 @@ async def assign_maintenance_type(
     async def remove_maintenance_item(
         item_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
     ):
         """Remove a maintenance item (unassign a custom type from a printer)."""
         result = await db.execute(
    @@ -464,6 +480,7 @@ async def perform_maintenance(
         item_id: int,
         data: PerformMaintenanceRequest,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     ):
         """Mark maintenance as performed (reset the counter)."""
         result = await db.execute(
    @@ -541,6 +558,7 @@ async def perform_maintenance(
     async def get_maintenance_history(
         item_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     ):
         """Get maintenance history for a specific item."""
         result = await db.execute(
    @@ -552,7 +570,10 @@ async def get_maintenance_history(
     
     
     @router.get("/summary")
    -async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
    +async def get_maintenance_summary(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
    +):
         """Get a summary of maintenance status across all printers."""
         await ensure_default_types(db)
     
    @@ -589,6 +610,7 @@ async def set_printer_hours(
         printer_id: int,
         total_hours: float,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     ):
         """Set the total print hours for a printer (adjusts offset to match).
     
    
  • backend/app/api/routes/notifications.py+20 2 modified
    @@ -8,8 +8,11 @@
     from sqlalchemy import delete, desc, func, select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.notification import NotificationLog, NotificationProvider
    +from backend.app.models.user import User
     from backend.app.schemas.notification import (
         NotificationLogResponse,
         NotificationLogStats,
    @@ -86,7 +89,10 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
     
     
     @router.get("/", response_model=list[NotificationProviderResponse])
    -async def list_notification_providers(db: AsyncSession = Depends(get_db)):
    +async def list_notification_providers(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
    +):
         """List all notification providers."""
         result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
         providers = result.scalars().all()
    @@ -98,6 +104,7 @@ async def list_notification_providers(db: AsyncSession = Depends(get_db)):
     async def create_notification_provider(
         provider_data: NotificationProviderCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
     ):
         """Create a new notification provider."""
         provider = NotificationProvider(
    @@ -153,6 +160,7 @@ async def create_notification_provider(
     async def test_notification_config(
         test_request: NotificationTestRequest,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
     ):
         """Test notification configuration before saving."""
         success, message = await notification_service.send_test_notification(
    @@ -163,7 +171,10 @@ async def test_notification_config(
     
     
     @router.post("/test-all")
    -async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
    +async def test_all_notification_providers(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
    +):
         """Send a test notification to all enabled providers."""
         result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
         providers = result.scalars().all()
    @@ -222,6 +233,7 @@ async def get_notification_logs(
         success: bool | None = Query(default=None),
         days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     ):
         """Get notification logs with optional filters."""
         query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
    @@ -278,6 +290,7 @@ async def get_notification_logs(
     async def get_notification_log_stats(
         days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     ):
         """Get notification log statistics."""
         cutoff = datetime.utcnow() - timedelta(days=days)
    @@ -323,6 +336,7 @@ async def get_notification_log_stats(
     async def clear_notification_logs(
         older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
     ):
         """Clear old notification logs."""
         cutoff = datetime.utcnow() - timedelta(days=older_than_days)
    @@ -345,6 +359,7 @@ async def clear_notification_logs(
     async def get_notification_provider(
         provider_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     ):
         """Get a specific notification provider."""
         result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
    @@ -361,6 +376,7 @@ async def update_notification_provider(
         provider_id: int,
         update_data: NotificationProviderUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     ):
         """Update a notification provider."""
         result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
    @@ -392,6 +408,7 @@ async def update_notification_provider(
     async def delete_notification_provider(
         provider_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
     ):
         """Delete a notification provider."""
         result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
    @@ -413,6 +430,7 @@ async def delete_notification_provider(
     async def test_notification_provider(
         provider_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
     ):
         """Send a test notification using an existing provider."""
         result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
    
  • backend/app/api/routes/notification_templates.py+25 5 modified
    @@ -4,8 +4,11 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
    +from backend.app.models.user import User
     from backend.app.schemas.notification_template import (
         EVENT_VARIABLES,
         SAMPLE_DATA,
    @@ -45,14 +48,19 @@
     
     @router.get("", response_model=list[NotificationTemplateResponse])
     @router.get("/", response_model=list[NotificationTemplateResponse])
    -async def get_templates(db: AsyncSession = Depends(get_db)):
    +async def get_templates(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
    +):
         """Get all notification templates."""
         result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
         return result.scalars().all()
     
     
     @router.get("/variables", response_model=list[EventVariablesResponse])
    -async def get_variables():
    +async def get_variables(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
    +):
         """Get available variables for each event type."""
         return [
             EventVariablesResponse(
    @@ -65,7 +73,11 @@ async def get_variables():
     
     
     @router.get("/{template_id}", response_model=NotificationTemplateResponse)
    -async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_template(
    +    template_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
    +):
         """Get a single notification template."""
         result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
         template = result.scalar_one_or_none()
    @@ -79,6 +91,7 @@ async def update_template(
         template_id: int,
         update: NotificationTemplateUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
     ):
         """Update a notification template."""
         result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
    @@ -101,7 +114,11 @@ async def update_template(
     
     
     @router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
    -async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
    +async def reset_template(
    +    template_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
    +):
         """Reset a notification template to its default values."""
         result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
         template = result.scalar_one_or_none()
    @@ -129,7 +146,10 @@ async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/preview", response_model=TemplatePreviewResponse)
    -async def preview_template(request: TemplatePreviewRequest):
    +async def preview_template(
    +    request: TemplatePreviewRequest,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
    +):
         """Preview a template with sample data."""
         sample = SAMPLE_DATA.get(request.event_type, {})
     
    
  • backend/app/api/routes/pending_uploads.py+22 4 modified
    @@ -8,8 +8,11 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.pending_upload import PendingUpload
    +from backend.app.models.user import User
     from backend.app.services.archive import ArchiveService
     
     router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
    @@ -41,7 +44,10 @@ class Config:
     
     
     @router.get("/", response_model=list[PendingUploadResponse])
    -async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
    +async def list_pending_uploads(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
    +):
         """List all pending uploads."""
         result = await db.execute(
             select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
    @@ -51,7 +57,10 @@ async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/count")
    -async def get_pending_count(db: AsyncSession = Depends(get_db)):
    +async def get_pending_count(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
    +):
         """Get count of pending uploads."""
         result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
         count = len(result.scalars().all())
    @@ -64,7 +73,10 @@ async def get_pending_count(db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/archive-all")
    -async def archive_all_pending(db: AsyncSession = Depends(get_db)):
    +async def archive_all_pending(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
    +):
         """Archive all pending uploads."""
         result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
         pending_uploads = result.scalars().all()
    @@ -117,7 +129,10 @@ async def archive_all_pending(db: AsyncSession = Depends(get_db)):
     
     
     @router.delete("/discard-all")
    -async def discard_all_pending(db: AsyncSession = Depends(get_db)):
    +async def discard_all_pending(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
    +):
         """Discard all pending uploads."""
         result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
         pending_uploads = result.scalars().all()
    @@ -144,6 +159,7 @@ async def discard_all_pending(db: AsyncSession = Depends(get_db)):
     async def get_pending_upload(
         upload_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
     ):
         """Get a specific pending upload."""
         result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
    @@ -160,6 +176,7 @@ async def archive_pending_upload(
         upload_id: int,
         request: ArchiveRequest = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     ):
         """Archive a pending upload."""
         result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
    @@ -227,6 +244,7 @@ async def archive_pending_upload(
     async def discard_pending_upload(
         upload_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
     ):
         """Discard a pending upload without archiving."""
         result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
    
  • backend/app/api/routes/printers.py+2 0 modified
    @@ -1361,6 +1361,7 @@ async def reset_ams_slot(
         ams_id: int,
         tray_id: int,
         db: AsyncSession = Depends(get_db),
    +    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     ):
         """Reset an AMS slot to empty/unconfigured state.
     
    @@ -1403,6 +1404,7 @@ async def reset_ams_slot(
     async def debug_simulate_print_complete(
         printer_id: int,
         db: AsyncSession = Depends(get_db),
    +    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     ):
         """DEBUG: Simulate print completion to test freeze behavior.
     
    
  • backend/app/api/routes/print_queue.py+11 3 modified
    @@ -12,7 +12,7 @@
     from sqlalchemy.ext.asyncio import AsyncSession
     from sqlalchemy.orm import selectinload
     
    -from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
     from backend.app.core.config import settings
     from backend.app.core.database import get_db
     from backend.app.core.permissions import Permission
    @@ -173,6 +173,7 @@ async def list_queue(
         printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
         status: str | None = Query(None, description="Filter by status"),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
     ):
         """List all queue items, optionally filtered by printer or status."""
         query = (
    @@ -204,7 +205,7 @@ async def list_queue(
     async def add_to_queue(
         data: PrintQueueItemCreate,
         db: AsyncSession = Depends(get_db),
    -    current_user: User | None = Depends(require_auth_if_enabled),
    +    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     ):
         """Add an item to the print queue."""
         # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
    @@ -425,7 +426,11 @@ async def bulk_update_queue_items(
     
     
     @router.get("/{item_id}", response_model=PrintQueueItemResponse)
    -async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_queue_item(
    +    item_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
    +):
         """Get a specific queue item."""
         result = await db.execute(
             select(PrintQueueItem)
    @@ -553,6 +558,7 @@ async def delete_queue_item(
     async def reorder_queue(
         data: PrintQueueReorder,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
     ):
         """Bulk update positions for queue items."""
         for reorder_item in data.items:
    @@ -605,6 +611,7 @@ async def cancel_queue_item(
     async def stop_queue_item(
         item_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
     ):
         """Stop an actively printing queue item."""
         import asyncio
    @@ -675,6 +682,7 @@ async def cooldown_and_poweroff():
     async def start_queue_item(
         item_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
     ):
         """Manually start a staged (manual_start) queue item.
     
    
  • backend/app/api/routes/projects.py+27 0 modified
    @@ -14,13 +14,16 @@
     from sqlalchemy.orm import selectinload
     
     from backend.app.api.routes.library import get_library_dir
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.config import settings
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.archive import PrintArchive
     from backend.app.models.library import LibraryFile, LibraryFolder
     from backend.app.models.print_queue import PrintQueueItem
     from backend.app.models.project import Project
     from backend.app.models.project_bom import ProjectBOMItem
    +from backend.app.models.user import User
     from backend.app.schemas.project import (
         ArchivePreview,
         BatchAddArchives,
    @@ -154,6 +157,7 @@ async def compute_project_stats(
     async def list_projects(
         status: str | None = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     ):
         """List all projects with basic stats."""
         query = select(Project)
    @@ -258,6 +262,7 @@ async def list_projects(
     async def create_project(
         data: ProjectCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
     ):
         """Create a new project."""
         # Verify parent exists if specified
    @@ -319,6 +324,7 @@ async def create_project(
     @router.get("/templates", response_model=list[ProjectListResponse])
     async def list_templates(
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     ):
         """List all project templates."""
         result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
    @@ -356,6 +362,7 @@ async def create_project_from_template(
         template_id: int,
         name: str = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
     ):
         """Create a new project from a template."""
         result = await db.execute(select(Project).where(Project.id == template_id))
    @@ -470,6 +477,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
     async def get_project(
         project_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     ):
         """Get a project by ID with detailed stats."""
         result = await db.execute(select(Project).where(Project.id == project_id))
    @@ -519,6 +527,7 @@ async def update_project(
         project_id: int,
         data: ProjectUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     ):
         """Update a project."""
         result = await db.execute(select(Project).where(Project.id == project_id))
    @@ -609,6 +618,7 @@ async def update_project(
     async def delete_project(
         project_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
     ):
         """Delete a project. Archives and queue items will have project_id set to NULL."""
         result = await db.execute(select(Project).where(Project.id == project_id))
    @@ -628,6 +638,7 @@ async def list_project_archives(
         limit: int = 100,
         offset: int = 0,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     ):
         """List archives in a project."""
         # Verify project exists
    @@ -657,6 +668,7 @@ async def list_project_archives(
     async def list_project_queue(
         project_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     ):
         """List queue items in a project."""
         # Verify project exists
    @@ -677,6 +689,7 @@ async def add_archives_to_project(
         project_id: int,
         data: BatchAddArchives,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     ):
         """Batch add archives to a project."""
         # Verify project exists
    @@ -701,6 +714,7 @@ async def add_queue_items_to_project(
         project_id: int,
         data: BatchAddQueueItems,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     ):
         """Batch add queue items to a project."""
         # Verify project exists
    @@ -725,6 +739,7 @@ async def remove_archives_from_project(
         project_id: int,
         data: BatchAddArchives,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     ):
         """Remove archives from a project (sets project_id to NULL)."""
         updated = 0
    @@ -811,6 +826,7 @@ async def upload_attachment(
         project_id: int,
         file: UploadFile = File(...),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     ):
         """Upload an attachment to a project."""
         logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
    @@ -888,6 +904,7 @@ async def download_attachment(
         project_id: int,
         filename: str,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     ):
         """Download an attachment from a project."""
         # Verify project exists
    @@ -919,6 +936,7 @@ async def delete_attachment(
         project_id: int,
         filename: str,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     ):
         """Delete an attachment from a project."""
         # Verify project exists
    @@ -962,6 +980,7 @@ async def delete_attachment(
     async def list_bom_items(
         project_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     ):
         """List all BOM items for a project."""
         # Verify project exists
    @@ -1013,6 +1032,7 @@ async def create_bom_item(
         project_id: int,
         data: BOMItemCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     ):
         """Add a BOM item to a project."""
         # Verify project exists
    @@ -1072,6 +1092,7 @@ async def update_bom_item(
         item_id: int,
         data: BOMItemUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     ):
         """Update a BOM item."""
         result = await db.execute(
    @@ -1135,6 +1156,7 @@ async def delete_bom_item(
         project_id: int,
         item_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
     ):
         """Delete a BOM item."""
         result = await db.execute(
    @@ -1157,6 +1179,7 @@ async def delete_bom_item(
     async def create_template_from_project(
         project_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
     ):
         """Create a template from an existing project."""
         result = await db.execute(select(Project).where(Project.id == project_id))
    @@ -1238,6 +1261,7 @@ async def get_project_timeline(
         project_id: int,
         limit: int = 50,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     ):
         """Get timeline of events for a project."""
         # Verify project exists
    @@ -1338,6 +1362,7 @@ async def export_project(
         project_id: int,
         format: str = "zip",  # "zip" (with files) or "json" (metadata only)
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     ):
         """Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
         result = await db.execute(select(Project).where(Project.id == project_id))
    @@ -1458,6 +1483,7 @@ async def export_project(
     async def import_project(
         data: ProjectImport,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
     ):
         """Import a project with optional BOM items and linked folders."""
         # Create the project
    @@ -1551,6 +1577,7 @@ async def import_project(
     async def import_project_file(
         file: UploadFile = File(...),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
     ):
         """Import a project from a ZIP or JSON file."""
         if not file.filename:
    
  • backend/app/api/routes/settings.py+32 7 modified
    @@ -8,9 +8,12 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.config import settings as app_settings
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.settings import Settings
    +from backend.app.models.user import User
     from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
     
     router = APIRouter(prefix="/settings", tags=["settings"])
    @@ -39,7 +42,10 @@ async def set_setting(db: AsyncSession, key: str, value: str) -> None:
     
     @router.get("", response_model=AppSettings)
     @router.get("/", response_model=AppSettings)
    -async def get_settings(db: AsyncSession = Depends(get_db)):
    +async def get_settings(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
    +):
         """Get all application settings."""
         settings_dict = DEFAULT_SETTINGS.model_dump()
     
    @@ -96,6 +102,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     async def update_settings(
         settings_update: AppSettingsUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     ):
         """Update application settings."""
         update_data = settings_update.model_dump(exclude_unset=True)
    @@ -153,13 +160,17 @@ async def update_settings(
     async def patch_settings(
         settings_update: AppSettingsUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     ):
         """Partially update application settings (same as PUT, for REST compatibility)."""
    -    return await update_settings(settings_update, db)
    +    return await update_settings(settings_update, db, _)
     
     
     @router.post("/reset", response_model=AppSettings)
    -async def reset_settings(db: AsyncSession = Depends(get_db)):
    +async def reset_settings(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
    +):
         """Reset all settings to defaults."""
         # Delete all settings
         result = await db.execute(select(Settings))
    @@ -185,7 +196,10 @@ async def check_ffmpeg():
     
     
     @router.get("/spoolman")
    -async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
    +async def get_spoolman_settings(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
    +):
         """Get Spoolman integration settings."""
         spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
         spoolman_url = await get_setting(db, "spoolman_url") or ""
    @@ -202,6 +216,7 @@ async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
     async def update_spoolman_settings(
         settings: dict,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     ):
         """Update Spoolman integration settings."""
         if "spoolman_enabled" in settings:
    @@ -219,7 +234,10 @@ async def update_spoolman_settings(
     
     
     @router.get("/backup")
    -async def create_backup(db: AsyncSession = Depends(get_db)):
    +async def create_backup(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
    +):
         """Create a complete backup (database + all files) as a ZIP.
     
         This is a simplified backup that includes the entire SQLite database
    @@ -280,6 +298,7 @@ async def create_backup(db: AsyncSession = Depends(get_db)):
     async def restore_backup(
         file: UploadFile = File(...),
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
     ):
         """Restore from a complete backup ZIP.
     
    @@ -363,7 +382,10 @@ async def get_virtual_printer_models():
     
     
     @router.get("/virtual-printer")
    -async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
    +async def get_virtual_printer_settings(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
    +):
         """Get virtual printer settings and status."""
         from backend.app.services.virtual_printer import (
             DEFAULT_VIRTUAL_PRINTER_MODEL,
    @@ -394,6 +416,7 @@ async def update_virtual_printer_settings(
         model: str = None,
         target_printer_id: int = None,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     ):
         """Update virtual printer settings and restart services if needed."""
         from sqlalchemy import select
    @@ -528,7 +551,9 @@ async def update_virtual_printer_settings(
     
     
     @router.get("/mqtt/status")
    -async def get_mqtt_status():
    +async def get_mqtt_status(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
    +):
         """Get MQTT relay connection status."""
         from backend.app.services.mqtt_relay import mqtt_relay
     
    
  • backend/app/api/routes/smart_plugs.py+61 13 modified
    @@ -9,9 +9,12 @@
     from sqlalchemy.ext.asyncio import AsyncSession
     
     from backend.app.api.routes.settings import get_setting
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.printer import Printer
     from backend.app.models.smart_plug import SmartPlug
    +from backend.app.models.user import User
     from backend.app.schemas.smart_plug import (
         HAEntity,
         HASensorEntity,
    @@ -38,7 +41,10 @@
     
     
     @router.get("/", response_model=list[SmartPlugResponse])
    -async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
    +async def list_smart_plugs(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """List all smart plugs."""
         result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
         return list(result.scalars().all())
    @@ -48,6 +54,7 @@ async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
     async def create_smart_plug(
         data: SmartPlugCreate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
     ):
         """Create a new smart plug."""
         # Validate printer_id if provided
    @@ -142,7 +149,11 @@ async def create_smart_plug(
     
     
     @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
    -async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_smart_plug_by_printer(
    +    printer_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """Get the main smart plug assigned to a printer.
     
         When multiple plugs are assigned (e.g., a regular plug + script),
    @@ -165,7 +176,11 @@ async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(
     
     
     @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
    -async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_script_plugs_by_printer(
    +    printer_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """Get all HA script plugs assigned to a printer.
     
         Returns only script entities (script.*) for the printer that have
    @@ -244,7 +259,10 @@ class DiscoveredTasmotaDevice(BaseModel):
     
     
     @router.post("/discover/scan", response_model=TasmotaScanStatus)
    -async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
    +async def start_tasmota_scan(
    +    request: TasmotaScanRequest | None = Body(default=None),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """Start an IP range scan for Tasmota devices.
     
         Auto-detects local network if no IP range provided.
    @@ -268,7 +286,9 @@ async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=N
     
     
     @router.get("/discover/status", response_model=TasmotaScanStatus)
    -async def get_tasmota_scan_status():
    +async def get_tasmota_scan_status(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """Get the current Tasmota scan status."""
         scanned, total = tasmota_scanner.progress
         return TasmotaScanStatus(
    @@ -279,7 +299,9 @@ async def get_tasmota_scan_status():
     
     
     @router.post("/discover/stop", response_model=TasmotaScanStatus)
    -async def stop_tasmota_scan():
    +async def stop_tasmota_scan(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """Stop the current Tasmota scan."""
         tasmota_scanner.stop()
         scanned, total = tasmota_scanner.progress
    @@ -291,7 +313,9 @@ async def stop_tasmota_scan():
     
     
     @router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
    -async def get_discovered_tasmota_devices():
    +async def get_discovered_tasmota_devices(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """Get list of discovered Tasmota devices."""
         return [
             DiscoveredTasmotaDevice(
    @@ -309,7 +333,10 @@ async def get_discovered_tasmota_devices():
     
     
     @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
    -async def test_ha_connection(request: HATestConnectionRequest):
    +async def test_ha_connection(
    +    request: HATestConnectionRequest,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
    +):
         """Test connection to Home Assistant."""
         result = await homeassistant_service.test_connection(request.url, request.token)
         return HATestConnectionResponse(**result)
    @@ -319,6 +346,7 @@ async def test_ha_connection(request: HATestConnectionRequest):
     async def list_ha_entities(
         db: AsyncSession = Depends(get_db),
         search: str | None = None,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     ):
         """List available Home Assistant entities.
     
    @@ -340,7 +368,10 @@ async def list_ha_entities(
     
     
     @router.get("/ha/sensors", response_model=list[HASensorEntity])
    -async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
    +async def list_ha_sensor_entities(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """List available Home Assistant sensor entities for energy monitoring.
     
         Returns sensors with power/energy units (W, kW, kWh, Wh).
    @@ -359,7 +390,11 @@ async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/{plug_id}", response_model=SmartPlugResponse)
    -async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_smart_plug(
    +    plug_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """Get a specific smart plug."""
         result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
         plug = result.scalar_one_or_none()
    @@ -373,6 +408,7 @@ async def update_smart_plug(
         plug_id: int,
         data: SmartPlugUpdate,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
     ):
         """Update a smart plug."""
         result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
    @@ -489,7 +525,11 @@ async def update_smart_plug(
     
     
     @router.delete("/{plug_id}")
    -async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
    +async def delete_smart_plug(
    +    plug_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
    +):
         """Delete a smart plug."""
         result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
         plug = result.scalar_one_or_none()
    @@ -529,6 +569,7 @@ async def control_smart_plug(
         plug_id: int,
         control: SmartPlugControl,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     ):
         """Manual control: on/off/toggle."""
         result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
    @@ -635,7 +676,11 @@ async def trigger_associated_scripts(printer_id: int, plug_state: str, db: Async
     
     
     @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
    -async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
    +async def get_plug_status(
    +    plug_id: int,
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
    +):
         """Get current plug status from device including energy data."""
         result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
         plug = result.scalar_one_or_none()
    @@ -763,7 +808,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
     
     
     @router.post("/test-connection")
    -async def test_connection(data: SmartPlugTestConnection):
    +async def test_connection(
    +    data: SmartPlugTestConnection,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
    +):
         """Test connection to a Tasmota device."""
         result = await tasmota_service.test_connection(
             data.ip_address,
    
  • backend/app/api/routes/spoolman.py+36 8 modified
    @@ -7,9 +7,12 @@
     from sqlalchemy import select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.printer import Printer
     from backend.app.models.settings import Settings
    +from backend.app.models.user import User
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.spoolman import (
         close_spoolman_client,
    @@ -72,7 +75,10 @@ async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
     
     
     @router.get("/status", response_model=SpoolmanStatus)
    -async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
    +async def get_spoolman_status(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
    +):
         """Get Spoolman integration status."""
         enabled, url, _ = await get_spoolman_settings(db)
     
    @@ -89,7 +95,10 @@ async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/connect")
    -async def connect_spoolman(db: AsyncSession = Depends(get_db)):
    +async def connect_spoolman(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
    +):
         """Connect to Spoolman server using configured URL."""
         enabled, url, _ = await get_spoolman_settings(db)
     
    @@ -119,7 +128,9 @@ async def connect_spoolman(db: AsyncSession = Depends(get_db)):
     
     
     @router.post("/disconnect")
    -async def disconnect_spoolman():
    +async def disconnect_spoolman(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
    +):
         """Disconnect from Spoolman server."""
         await close_spoolman_client()
         return {"success": True, "message": "Disconnected from Spoolman"}
    @@ -129,6 +140,7 @@ async def disconnect_spoolman():
     async def sync_printer_ams(
         printer_id: int,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     ):
         """Sync AMS data from a specific printer to Spoolman."""
         # Check if Spoolman is enabled and connected
    @@ -267,7 +279,10 @@ async def sync_printer_ams(
     
     
     @router.post("/sync-all", response_model=SyncResult)
    -async def sync_all_printers(db: AsyncSession = Depends(get_db)):
    +async def sync_all_printers(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
    +):
         """Sync AMS data from all connected printers to Spoolman."""
         # Check if Spoolman is enabled
         enabled, url, _ = await get_spoolman_settings(db)
    @@ -392,7 +407,10 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/spools")
    -async def get_spools(db: AsyncSession = Depends(get_db)):
    +async def get_spools(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
    +):
         """Get all spools from Spoolman."""
         enabled, url, _ = await get_spoolman_settings(db)
         if not enabled:
    @@ -413,7 +431,10 @@ async def get_spools(db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/filaments")
    -async def get_filaments(db: AsyncSession = Depends(get_db)):
    +async def get_filaments(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
    +):
         """Get all filaments from Spoolman."""
         enabled, url, _ = await get_spoolman_settings(db)
         if not enabled:
    @@ -445,7 +466,10 @@ class UnlinkedSpool(BaseModel):
     
     
     @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
    -async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
    +async def get_unlinked_spools(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
    +):
         """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
         enabled, url, _ = await get_spoolman_settings(db)
         if not enabled:
    @@ -487,7 +511,10 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
     
     
     @router.get("/spools/linked")
    -async def get_linked_spools(db: AsyncSession = Depends(get_db)):
    +async def get_linked_spools(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
    +):
         """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
         enabled, url, _ = await get_spoolman_settings(db)
         if not enabled:
    @@ -530,6 +557,7 @@ async def link_spool(
         spool_id: int,
         request: LinkSpoolRequest,
         db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     ):
         """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
         enabled, url, _ = await get_spoolman_settings(db)
    
  • backend/app/api/routes/support.py+17 4 modified
    @@ -15,14 +15,17 @@
     from sqlalchemy import func, select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.config import APP_VERSION, settings
     from backend.app.core.database import async_session
    +from backend.app.core.permissions import Permission
     from backend.app.models.archive import PrintArchive
     from backend.app.models.filament import Filament
     from backend.app.models.printer import Printer
     from backend.app.models.project import Project
     from backend.app.models.settings import Settings
     from backend.app.models.smart_plug import SmartPlug
    +from backend.app.models.user import User
     
     router = APIRouter(prefix="/support", tags=["support"])
     logger = logging.getLogger(__name__)
    @@ -107,7 +110,9 @@ def _apply_log_level(debug: bool):
     
     
     @router.get("/debug-logging", response_model=DebugLoggingState)
    -async def get_debug_logging_state():
    +async def get_debug_logging_state(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
    +):
         """Get current debug logging state."""
         global _debug_logging_enabled, _debug_logging_enabled_at
     
    @@ -128,7 +133,10 @@ async def get_debug_logging_state():
     
     
     @router.post("/debug-logging", response_model=DebugLoggingState)
    -async def toggle_debug_logging(toggle: DebugLoggingToggle):
    +async def toggle_debug_logging(
    +    toggle: DebugLoggingToggle,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
    +):
         """Enable or disable debug logging."""
         global _debug_logging_enabled, _debug_logging_enabled_at
     
    @@ -273,6 +281,7 @@ async def get_logs(
         limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
         level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
         search: str | None = Query(None, description="Search in message or logger name"),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
     ):
         """Get recent application log entries with optional filtering."""
         entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
    @@ -285,7 +294,9 @@ async def get_logs(
     
     
     @router.delete("/logs")
    -async def clear_logs():
    +async def clear_logs(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
    +):
         """Clear the application log file."""
         log_file = settings.log_dir / "bambuddy.log"
     
    @@ -445,7 +456,9 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
     
     
     @router.get("/bundle")
    -async def generate_support_bundle():
    +async def generate_support_bundle(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
    +):
         """Generate a support bundle ZIP file for issue reporting."""
         global _debug_logging_enabled, _debug_logging_enabled_at
     
    
  • backend/app/api/routes/system.py+7 1 modified
    @@ -9,13 +9,16 @@
     from sqlalchemy import func, select
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.config import APP_VERSION, settings
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
     from backend.app.models.archive import PrintArchive
     from backend.app.models.filament import Filament
     from backend.app.models.printer import Printer
     from backend.app.models.project import Project
     from backend.app.models.smart_plug import SmartPlug
    +from backend.app.models.user import User
     from backend.app.services.printer_manager import printer_manager
     
     router = APIRouter(prefix="/system", tags=["system"])
    @@ -60,7 +63,10 @@ def format_uptime(seconds: float) -> str:
     
     
     @router.get("/info")
    -async def get_system_info(db: AsyncSession = Depends(get_db)):
    +async def get_system_info(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
    +):
         """Get comprehensive system information."""
     
         # Database stats
    
  • backend/app/api/routes/updates.py+18 4 modified
    @@ -11,8 +11,11 @@
     from fastapi import APIRouter, BackgroundTasks, Depends
     from sqlalchemy.ext.asyncio import AsyncSession
     
    +from backend.app.core.auth import RequirePermissionIfAuthEnabled
     from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
     from backend.app.core.database import get_db
    +from backend.app.core.permissions import Permission
    +from backend.app.models.user import User
     
     logger = logging.getLogger(__name__)
     
    @@ -153,15 +156,21 @@ def is_newer_version(latest: str, current: str) -> bool:
     
     @router.get("/version")
     async def get_version():
    -    """Get current application version."""
    +    """Get current application version.
    +
    +    Note: Unauthenticated - needed to display version in UI without login.
    +    """
         return {
             "version": APP_VERSION,
             "repo": GITHUB_REPO,
         }
     
     
     @router.get("/check")
    -async def check_for_updates(db: AsyncSession = Depends(get_db)):
    +async def check_for_updates(
    +    db: AsyncSession = Depends(get_db),
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
    +):
         """Check GitHub for available updates."""
         global _update_status
     
    @@ -432,7 +441,10 @@ async def _perform_update():
     
     
     @router.post("/apply")
    -async def apply_update(background_tasks: BackgroundTasks):
    +async def apply_update(
    +    background_tasks: BackgroundTasks,
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
    +):
         """Apply available update (git pull + rebuild)."""
         global _update_status
     
    @@ -473,6 +485,8 @@ async def apply_update(background_tasks: BackgroundTasks):
     
     
     @router.get("/status")
    -async def get_update_status():
    +async def get_update_status(
    +    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
    +):
         """Get current update status."""
         return _update_status
    
  • backend/tests/integration/test_endpoint_auth.py+173 0 added
    @@ -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"
    
  • CHANGELOG.md+8 0 modified
    @@ -4,6 +4,14 @@ All notable changes to Bambuddy will be documented in this file.
     
     ## [0.1.7b] - Not released
     
    +### Security
    +- **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):
    +  - Added authentication to 200+ API endpoints that were previously unprotected
    +  - All route files now use `RequirePermissionIfAuthEnabled()` for permission checks
    +  - Protected endpoints: archives, projects, settings, API keys, groups, cloud, notifications, maintenance, filaments, external links, smart plugs, discovery, firmware, camera, k-profiles, AMS history, pending uploads, updates, spoolman, system, print queue, printers
    +  - Image-serving endpoints (thumbnails, timelapse, photos, camera streams) remain public as they require knowing the resource ID and are loaded via `<img>` tags which cannot send Authorization headers
    +  - Backend integration tests added to verify endpoint authentication enforcement
    +
     ### Enhancements
     - **TOTP Authenticator Support for Bambu Cloud** (Issue #182):
       - Added support for TOTP-based two-factor authentication when connecting to Bambu Cloud
    
  • frontend/src/__tests__/contexts/AuthContext.test.tsx+95 0 modified
    @@ -166,4 +166,99 @@ describe('AuthContext', () => {
           expect(result.current.hasPermission('printers:read' as Permission)).toBe(false);
         });
       });
    +
    +  describe('CVE-2026-25505 fix: auth disabled grants all access', () => {
    +    beforeEach(() => {
    +      server.use(
    +        http.get('/api/v1/auth/status', () => {
    +          return HttpResponse.json({
    +            auth_enabled: false,
    +            requires_setup: false,
    +          });
    +        })
    +      );
    +    });
    +
    +    it('isAdmin is true when auth is disabled', async () => {
    +      const { result } = renderHook(() => useAuth(), {
    +        wrapper: createWrapper(),
    +      });
    +
    +      await waitFor(() => {
    +        expect(result.current.loading).toBe(false);
    +      });
    +
    +      // When auth disabled, user is treated as admin
    +      expect(result.current.isAdmin).toBe(true);
    +    });
    +
    +    it('canModify allows all modifications when auth disabled', async () => {
    +      const { result } = renderHook(() => useAuth(), {
    +        wrapper: createWrapper(),
    +      });
    +
    +      await waitFor(() => {
    +        expect(result.current.loading).toBe(false);
    +      });
    +
    +      // All canModify checks should pass when auth is disabled
    +      expect(result.current.canModify('queue', 'update', 1)).toBe(true);
    +      expect(result.current.canModify('queue', 'update', 999)).toBe(true);
    +      expect(result.current.canModify('queue', 'update', null)).toBe(true);
    +      expect(result.current.canModify('archives', 'delete', 1)).toBe(true);
    +      expect(result.current.canModify('library', 'update', null)).toBe(true);
    +    });
    +
    +    it('all permissions are granted when auth is disabled', async () => {
    +      const { result } = renderHook(() => useAuth(), {
    +        wrapper: createWrapper(),
    +      });
    +
    +      await waitFor(() => {
    +        expect(result.current.loading).toBe(false);
    +      });
    +
    +      // All permission checks should pass
    +      expect(result.current.hasPermission('archives:read' as Permission)).toBe(true);
    +      expect(result.current.hasPermission('archives:delete_all' as Permission)).toBe(true);
    +      expect(result.current.hasPermission('settings:update' as Permission)).toBe(true);
    +      expect(result.current.hasPermission('api_keys:create' as Permission)).toBe(true);
    +      expect(result.current.hasPermission('groups:delete' as Permission)).toBe(true);
    +    });
    +
    +    it('hasAnyPermission returns true for protected permissions', async () => {
    +      const { result } = renderHook(() => useAuth(), {
    +        wrapper: createWrapper(),
    +      });
    +
    +      await waitFor(() => {
    +        expect(result.current.loading).toBe(false);
    +      });
    +
    +      expect(
    +        result.current.hasAnyPermission(
    +          'api_keys:create' as Permission,
    +          'groups:delete' as Permission
    +        )
    +      ).toBe(true);
    +    });
    +
    +    it('hasAllPermissions returns true for any combination', async () => {
    +      const { result } = renderHook(() => useAuth(), {
    +        wrapper: createWrapper(),
    +      });
    +
    +      await waitFor(() => {
    +        expect(result.current.loading).toBe(false);
    +      });
    +
    +      expect(
    +        result.current.hasAllPermissions(
    +          'settings:update' as Permission,
    +          'api_keys:create' as Permission,
    +          'groups:delete' as Permission
    +        )
    +      ).toBe(true);
    +    });
    +  });
     });
    
  • README.md+1 0 modified
    @@ -158,6 +158,7 @@
     - Group-based permissions (50+ granular permissions)
     - Default groups: Administrators, Operators, Viewers
     - JWT tokens with secure password hashing
    +- Comprehensive API protection (200+ endpoints secured)
     - User management (create, edit, delete, groups)
     - User activity tracking (who uploaded archives, library files, queued prints, started prints)
     
    
c31f2968889c

Fix critical security vulnerabilities (GHSA-gc24-px2r-5qmf)

https://github.com/maziggy/bambuddymaziggyFeb 2, 2026via ghsa
4 files changed · +183 4
  • backend/app/core/auth.py+58 1 modified
    @@ -1,7 +1,10 @@
     from __future__ import annotations
     
    +import logging
    +import os
     import secrets
     from datetime import datetime, timedelta
    +from pathlib import Path
     from typing import Annotated
     
     import jwt
    @@ -19,13 +22,67 @@
     from backend.app.models.settings import Settings
     from backend.app.models.user import User
     
    +logger = logging.getLogger(__name__)
    +
     # Password hashing
     # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
     # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations
     pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
     
    +
    +def _get_jwt_secret() -> str:
    +    """Get the JWT secret key from environment, file, or generate a new one.
    +
    +    Priority:
    +    1. JWT_SECRET_KEY environment variable
    +    2. .jwt_secret file in data directory
    +    3. Generate new random secret and save to file
    +
    +    Returns:
    +        The JWT secret key
    +    """
    +    # 1. Check environment variable first
    +    env_secret = os.environ.get("JWT_SECRET_KEY")
    +    if env_secret:
    +        logger.info("Using JWT secret from JWT_SECRET_KEY environment variable")
    +        return env_secret
    +
    +    # 2. Check for secret file in data directory
    +    data_dir = Path(os.environ.get("BAMBUDDY_DATA_DIR", "/app/data"))
    +    secret_file = data_dir / ".jwt_secret"
    +
    +    if secret_file.exists():
    +        try:
    +            secret = secret_file.read_text().strip()
    +            if secret and len(secret) >= 32:
    +                logger.info("Using JWT secret from %s", secret_file)
    +                return secret
    +        except Exception as e:
    +            logger.warning("Failed to read JWT secret file: %s", e)
    +
    +    # 3. Generate new random secret
    +    new_secret = secrets.token_urlsafe(64)
    +
    +    # Try to save it
    +    try:
    +        data_dir.mkdir(parents=True, exist_ok=True)
    +        secret_file.write_text(new_secret)
    +        # Restrict permissions (owner read/write only)
    +        secret_file.chmod(0o600)
    +        logger.info("Generated new JWT secret and saved to %s", secret_file)
    +    except Exception as e:
    +        logger.warning(
    +            "Could not save JWT secret to file (%s). "
    +            "Secret will be regenerated on restart, invalidating existing tokens. "
    +            "Set JWT_SECRET_KEY environment variable for persistence.",
    +            e,
    +        )
    +
    +    return new_secret
    +
    +
     # JWT settings
    -SECRET_KEY = "bambuddy-secret-key-change-in-production"  # TODO: Move to settings/env
    +SECRET_KEY = _get_jwt_secret()
     ALGORITHM = "HS256"
     ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7 days
     
    
  • backend/app/main.py+115 0 modified
    @@ -2537,6 +2537,121 @@ async def on_layer_change(printer_id: int, layer_num: int):
         lifespan=lifespan,
     )
     
    +
    +# =============================================================================
    +# Authentication Middleware - Secures ALL API routes by default
    +# =============================================================================
    +# Public routes that don't require authentication even when auth is enabled
    +PUBLIC_API_ROUTES = {
    +    # Auth routes needed before login
    +    "/api/v1/auth/status",
    +    "/api/v1/auth/login",
    +    # Version check for updates (no sensitive data)
    +    "/api/v1/updates/version",
    +}
    +
    +# Route prefixes that are public (for routes with dynamic segments)
    +PUBLIC_API_PREFIXES = [
    +    # WebSocket connections handle their own auth
    +    "/api/v1/ws",
    +]
    +
    +
    +@app.middleware("http")
    +async def auth_middleware(request, call_next):
    +    """Enforce authentication on all API routes when auth is enabled.
    +
    +    This middleware provides defense-in-depth by checking auth at the API gateway level,
    +    regardless of whether individual routes have auth dependencies.
    +    """
    +    from starlette.responses import JSONResponse
    +
    +    path = request.url.path
    +
    +    # Only apply to API routes
    +    if not path.startswith("/api/"):
    +        return await call_next(request)
    +
    +    # Allow public routes
    +    if path in PUBLIC_API_ROUTES:
    +        return await call_next(request)
    +
    +    # Allow public prefixes
    +    for prefix in PUBLIC_API_PREFIXES:
    +        if path.startswith(prefix):
    +            return await call_next(request)
    +
    +    # Check if auth is enabled
    +    try:
    +        async with async_session() as db:
    +            from backend.app.core.auth import is_auth_enabled
    +
    +            auth_enabled = await is_auth_enabled(db)
    +
    +        if not auth_enabled:
    +            # Auth disabled, allow all requests
    +            return await call_next(request)
    +    except Exception:
    +        # If we can't check auth status, allow request (fail open for DB issues)
    +        return await call_next(request)
    +
    +    # Auth is enabled - require valid token
    +    auth_header = request.headers.get("Authorization")
    +    x_api_key = request.headers.get("X-API-Key")
    +
    +    # Check for API key auth first
    +    if x_api_key or (auth_header and auth_header.startswith("Bearer bb_")):
    +        # API key authentication - let the request through to be validated by route handler
    +        # API keys are validated per-route since they have different permission levels
    +        return await call_next(request)
    +
    +    # Check for JWT auth
    +    if not auth_header or not auth_header.startswith("Bearer "):
    +        return JSONResponse(
    +            status_code=401,
    +            content={"detail": "Authentication required"},
    +            headers={"WWW-Authenticate": "Bearer"},
    +        )
    +
    +    # Validate JWT token
    +    try:
    +        import jwt
    +
    +        from backend.app.core.auth import ALGORITHM, SECRET_KEY
    +
    +        token = auth_header.replace("Bearer ", "")
    +        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    +        username = payload.get("sub")
    +        if not username:
    +            raise ValueError("No username in token")
    +
    +        # Verify user exists and is active
    +        async with async_session() as db:
    +            from backend.app.core.auth import get_user_by_username
    +
    +            user = await get_user_by_username(db, username)
    +            if not user or not user.is_active:
    +                return JSONResponse(
    +                    status_code=401,
    +                    content={"detail": "User not found or inactive"},
    +                    headers={"WWW-Authenticate": "Bearer"},
    +                )
    +    except jwt.ExpiredSignatureError:
    +        return JSONResponse(
    +            status_code=401,
    +            content={"detail": "Token has expired"},
    +            headers={"WWW-Authenticate": "Bearer"},
    +        )
    +    except (jwt.InvalidTokenError, ValueError, Exception):
    +        return JSONResponse(
    +            status_code=401,
    +            content={"detail": "Invalid token"},
    +            headers={"WWW-Authenticate": "Bearer"},
    +        )
    +
    +    return await call_next(request)
    +
    +
     # API routes
     app.include_router(auth.router, prefix=app_settings.api_prefix)
     app.include_router(users.router, prefix=app_settings.api_prefix)
    
  • backend/tests/conftest.py+2 1 modified
    @@ -117,10 +117,11 @@ async def override_get_db():
         async def mock_init_printer_connections(db):
             pass  # No-op - don't connect to real printers
     
    -    # Also patch the module-level async_session used by services and auth
    +    # Also patch the module-level async_session used by services, auth, and middleware
         with (
             patch("backend.app.core.database.async_session", test_async_session),
             patch("backend.app.core.auth.async_session", test_async_session),
    +        patch("backend.app.main.async_session", test_async_session),
             patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
         ):
             # Seed default groups for tests that need them
    
  • backend/tests/integration/test_ownership_permissions.py+8 2 modified
    @@ -700,7 +700,10 @@ async def test_delete_user_keeps_items(
             assert response.status_code == 204
     
             # Verify archive still exists but is now ownerless
    -        archive_response = await async_client.get(f"/api/v1/archives/{archive_id}")
    +        archive_response = await async_client.get(
    +            f"/api/v1/archives/{archive_id}",
    +            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
    +        )
             assert archive_response.status_code == 200
             assert archive_response.json()["created_by_id"] is None
     
    @@ -736,5 +739,8 @@ async def test_delete_user_with_items(
             assert response.status_code == 204
     
             # Verify archive was deleted
    -        archive_response = await async_client.get(f"/api/v1/archives/{archive_id}")
    +        archive_response = await async_client.get(
    +            f"/api/v1/archives/{archive_id}",
    +            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
    +        )
             assert archive_response.status_code == 404
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

9

News mentions

0

No linked articles in our index yet.