Security hardening (maziggy/bambuddy-security #10)

This commit is contained in:
maziggy
2026-10-06 11:48:37 +02:00
parent 2022824b29
commit cf98daa0bb
20 changed files with 872 additions and 110 deletions
+12 -7
View File
@@ -17,7 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core import database
from backend.app.core.auth import (
ApiKeyActor,
MediaOrRequestPrinterScope,
RequestActor,
RequestPrinterScope,
RequirePermissionIfAuthEnabled,
probe_permissions_if_auth_enabled,
@@ -218,7 +220,7 @@ def _ensure_archive_visible(
return archive
def _validate_user_filter_permission(current_user: User | None, created_by_id: int | None):
def _validate_user_filter_permission(current_user: User | ApiKeyActor | None, created_by_id: int | None):
"""Raise 403 if created_by_id filter is used without stats:filter_by_user permission."""
if created_by_id is None or current_user is None:
return
@@ -1139,9 +1141,10 @@ async def export_stats(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
printer_scope: PrinterScope = RequestPrinterScope,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Export statistics summary to CSV or Excel format."""
_validate_user_filter_permission(current_user, created_by_id)
_validate_user_filter_permission(actor, created_by_id)
from fastapi.responses import StreamingResponse
@@ -1178,6 +1181,7 @@ async def get_archive_stats(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
printer_scope: PrinterScope = RequestPrinterScope,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Get statistics across all archives.
@@ -1188,7 +1192,7 @@ async def get_archive_stats(
"""
from backend.app.models.print_log import PrintLogEntry
_validate_user_filter_permission(current_user, created_by_id)
_validate_user_filter_permission(actor, created_by_id)
# Build date filter conditions scoped to PrintLogEntry (event-time).
base_conditions = []
@@ -4915,6 +4919,7 @@ async def slice_archive(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
printer_scope: PrinterScope = MediaOrRequestPrinterScope,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Enqueue a slice job for an archive's source. Returns 202 + job_id;
the slice runs in the background, the caller polls `GET /slice-jobs/{id}`.
@@ -4933,10 +4938,10 @@ async def slice_archive(
archive = await db.get(PrintArchive, archive_id)
# Per-row ownership gate — mirror the archive read routes. LIBRARY_UPLOAD
# alone let a READ_OWN caller slice another user's archive by raw id even
# though GET on that id returned 404. API-key / auth-disabled callers
# (current_user is None) keep can_read_all=True — no per-row identity.
can_read_all = current_user is None or current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
archive = _ensure_archive_visible(archive, current_user, can_read_all, printer_scope)
# though GET on that id returned 404. An API key is checked as its owner
# (RequestActor); only auth off keeps can_read_all=True.
can_read_all = actor is None or actor.has_permission(Permission.ARCHIVES_READ_ALL.value)
archive = _ensure_archive_visible(archive, actor, can_read_all, printer_scope)
src_relative = archive.source_3mf_path or archive.file_path
if not src_relative:
+13 -2
View File
@@ -7,7 +7,7 @@ from sqlalchemy import case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
from backend.app.core.auth import ApiKeyActor, RequestActor, RequirePermissionIfAuthEnabled, require_auth_if_enabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.finance import (
@@ -638,8 +638,19 @@ async def create_manual_print(
async def get_my_cost_centers(
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_auth_if_enabled),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Return private and assigned cost centers for the current user."""
"""Return private and assigned cost centers for the current user.
An API key that may queue gets its owner's, the ones it can queue with
when billing is on (#3256). A key without an owner has none.
"""
if isinstance(actor, ApiKeyActor):
if not actor.has_permission(Permission.QUEUE_CREATE.value):
raise HTTPException(status_code=403, detail="API key cannot queue prints")
if actor.owner is None:
return []
current_user = actor.owner
user = await _require_authenticated_user(current_user)
result = await db.execute(
+37 -28
View File
@@ -25,7 +25,9 @@ from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
from backend.app.core.auth import (
ApiKeyActor,
QueueReviewRequired,
RequestActor,
RequestPrinterScope,
require_media_token_ownership,
require_ownership_permission,
@@ -1240,11 +1242,12 @@ async def create_folder(
data: FolderCreate,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Create a new folder, owned by the user who makes it (#3201)."""
# A read_own user may only create inside their own or a shared folder.
if data.parent_id is not None:
await get_writable_folder(db, data.parent_id, current_user)
await get_writable_folder(db, data.parent_id, actor)
# Verify project exists if specified
project_name = None
@@ -1269,14 +1272,14 @@ async def create_folder(
parent_id=data.parent_id,
project_id=data.project_id,
archive_id=data.archive_id,
created_by_id=current_user.id if current_user else None,
# Made without a user (auth off, an API key): everyone's, as before #3201.
shared=current_user is None,
created_by_id=actor.id if actor else None,
# Made without a user (auth off, a key without an owner): everyone's, as before #3201.
shared=actor is None or actor.id is None,
)
db.add(folder)
await db.commit()
await db.refresh(folder)
index = await _load_index(db, current_user)
index = await _load_index(db, actor)
return FolderResponse(
id=folder.id,
@@ -1294,7 +1297,7 @@ async def create_folder(
# New folder has no files yet — fall back to the folder's own
# updated_at so this matches the list-route semantics (#1770).
latest_activity_at=folder.updated_at,
**_folder_access_fields(index, folder, current_user),
**_folder_access_fields(index, folder, actor),
created_at=folder.created_at,
updated_at=folder.updated_at,
)
@@ -1874,6 +1877,7 @@ async def scan_external_folder(
folder_id: int,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Scan an external folder and sync files to the database.
@@ -1881,7 +1885,7 @@ async def scan_external_folder(
Does not copy files — stores the external path directly.
"""
# A mount the user can't see is 404, like a missing one (#3201).
folder = await get_visible_folder(db, folder_id, current_user)
folder = await get_visible_folder(db, folder_id, actor)
if not folder.is_external or not folder.external_path:
raise HTTPException(status_code=400, detail="Not an external folder")
@@ -2393,6 +2397,7 @@ async def upload_file(
generate_stl_thumbnails: bool = Query(default=True),
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Upload a file to the library."""
try:
@@ -2415,7 +2420,7 @@ async def upload_file(
# Verify folder exists if specified, and that the user may add to it (#3201)
target_folder = None
if folder_id is not None:
target_folder = await get_writable_folder(db, folder_id, current_user)
target_folder = await get_writable_folder(db, folder_id, actor)
# Writable external folders write through to the mount so the file is
# visible outside Bambuddy (#1112); everything else lands under the
@@ -2538,7 +2543,7 @@ async def upload_file(
file_hash=file_hash,
thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
file_metadata=_without_print_name(metadata) if metadata else None,
created_by_id=current_user.id if current_user else None,
created_by_id=actor.id if actor else None,
)
db.add(library_file)
await db.commit()
@@ -2569,6 +2574,7 @@ async def extract_zip_file(
generate_stl_thumbnails: bool = Query(default=True),
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Upload and extract a ZIP file to the library.
@@ -2586,7 +2592,7 @@ async def extract_zip_file(
# Verify target folder exists if specified, and that the user may add to it (#3201)
if folder_id is not None:
target_folder = await get_writable_folder(db, folder_id, current_user)
target_folder = await get_writable_folder(db, folder_id, actor)
if target_folder.is_external and target_folder.external_readonly:
raise HTTPException(status_code=403, detail="Cannot extract ZIP to a read-only external folder")
if target_folder.is_external:
@@ -2635,7 +2641,7 @@ async def extract_zip_file(
# Reuse a same-named folder only when the user may write to it; never
# extract into someone else's folder that happens to share the name (#3201).
existing_folder = next(
(f for f in existing.scalars().all() if can_write_folder(f, current_user)),
(f for f in existing.scalars().all() if can_write_folder(f, actor)),
None,
)
if existing_folder:
@@ -2646,9 +2652,9 @@ async def extract_zip_file(
new_folder = LibraryFolder(
name=zip_folder_name,
parent_id=folder_id,
created_by_id=current_user.id if current_user else None,
created_by_id=actor.id if actor else None,
# Made without a user (auth off, an API key): everyone's, as before #3201.
shared=current_user is None,
shared=actor is None or actor.id is None,
)
db.add(new_folder)
await db.flush()
@@ -2700,7 +2706,7 @@ async def extract_zip_file(
)
)
existing_folder = next(
(f for f in existing.scalars().all() if can_write_folder(f, current_user)),
(f for f in existing.scalars().all() if can_write_folder(f, actor)),
None,
)
@@ -2711,9 +2717,9 @@ async def extract_zip_file(
new_folder = LibraryFolder(
name=part,
parent_id=current_parent,
created_by_id=current_user.id if current_user else None,
created_by_id=actor.id if actor else None,
# Made without a user (auth off, an API key): everyone's, as before #3201.
shared=current_user is None,
shared=actor is None or actor.id is None,
)
db.add(new_folder)
await db.flush()
@@ -2825,7 +2831,7 @@ async def extract_zip_file(
file_hash=file_hash,
thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
file_metadata=_without_print_name(metadata) if metadata else None,
created_by_id=current_user.id if current_user else None,
created_by_id=actor.id if actor else None,
)
db.add(library_file)
await db.flush()
@@ -3024,6 +3030,7 @@ async def combine_files(
request: CombineFilesRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Combine STL library files into one multi-object 3MF.
@@ -3044,11 +3051,11 @@ async def combine_files(
raise HTTPException(status_code=400, detail=str(e)) from e
if request.folder_id is not None:
await get_writable_folder(db, request.folder_id, current_user)
await get_writable_folder(db, request.folder_id, actor)
# Same per-row visibility the slice route applies: a READ_OWN caller must
# not be able to pull another user's model into their own file by raw id.
can_read_all = current_user is None or current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
can_read_all = actor is None or actor.has_permission(Permission.LIBRARY_READ_ALL.value)
# The same file listed twice is one object with the copies added up, so
# its mesh is loaded and stored once. Order follows first appearance.
@@ -3061,7 +3068,7 @@ async def combine_files(
# Gate every source before touching any of them on disk, so the answer for
# a file the caller can't see is the same 404 whatever else is in the list.
sources = [_ensure_library_file_visible(by_id.get(file_id), current_user, can_read_all) for file_id in copies_by_id]
sources = [_ensure_library_file_visible(by_id.get(file_id), actor, can_read_all) for file_id in copies_by_id]
parts: list[CombinePart] = []
for lib_file in sources:
@@ -3086,7 +3093,7 @@ async def combine_files(
filename=filename,
folder_id=request.folder_id,
source_type="combined",
owner_id=current_user.id if current_user else None,
owner_id=actor.id if actor else None,
)
return FileUploadResponse(
@@ -3106,6 +3113,7 @@ async def add_files_to_queue(
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
printer_scope: PrinterScope = RequestPrinterScope,
review_required: bool = QueueReviewRequired,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Add library files to the print queue.
@@ -3167,8 +3175,8 @@ async def add_files_to_queue(
# same "File not found" an unknown id gets and the response says nothing
# about which ids exist. Ownerless rows need LIBRARY_READ_ALL, matching
# _ensure_library_file_visible.
if current_user is not None and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value):
files = {fid: f for fid, f in files.items() if f.created_by_id == current_user.id}
if actor is not None and not actor.has_permission(Permission.LIBRARY_READ_ALL.value):
files = {fid: f for fid, f in files.items() if f.created_by_id == actor.id}
# Project attribution (#1897): a file queued from a project-linked folder
# inherits that project, so the resulting archive counts toward the
@@ -3286,7 +3294,7 @@ async def add_files_to_queue(
# Without this the row is ownerless, and `queue:read_own` filters
# on `created_by_id` — so the user who queued the file could not
# see it in their own queue.
created_by_id=current_user.id if current_user else None,
created_by_id=actor.id if actor else None,
# Waits for someone to start it unless they may print without review (#1620)
manual_start=review_required,
)
@@ -5123,6 +5131,7 @@ async def slice_library_file(
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Enqueue a slice job for a library file. Returns 202 + job_id; the
slice runs in the background, the caller polls `GET /slice-jobs/{id}`.
@@ -5139,10 +5148,10 @@ async def slice_library_file(
# built-in Operators group) slice another user's model by raw id even though
# GET on that id returned 404 — the sliced output was then attributed to and
# downloadable by the requester. Enforce the same visibility the read routes
# use before reading the source off disk. API-key / auth-disabled callers
# (current_user is None) keep can_read_all=True — no per-row identity.
can_read_all = current_user is None or current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
lib_file = _ensure_library_file_visible(lib_file, current_user, can_read_all)
# use before reading the source off disk. An API key is checked as its
# owner (RequestActor); only auth off keeps can_read_all=True.
can_read_all = actor is None or actor.has_permission(Permission.LIBRARY_READ_ALL.value)
lib_file = _ensure_library_file_visible(lib_file, actor, can_read_all)
src_lower = (lib_file.filename or "").lower()
if src_lower.endswith(".step") or src_lower.endswith(".stp"):
+8 -8
View File
@@ -32,6 +32,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
from backend.app.api.routes.library import save_3mf_bytes_to_library
from backend.app.core.auth import (
ApiKeyActor,
RequestActor,
RequirePermissionIfAuthEnabled,
require_auth_if_enabled,
require_permission_if_auth_enabled,
@@ -319,6 +321,7 @@ async def import_instance(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Download a specific MakerWorld instance (plate configuration) and save
the 3MF into the library.
@@ -338,7 +341,7 @@ async def import_instance(
if body.folder_id is not None:
# Only into a folder the user may write to (#3201).
target_folder = await get_writable_folder(db, body.folder_id, current_user)
target_folder = await get_writable_folder(db, body.folder_id, actor)
if target_folder.is_external and target_folder.external_readonly:
raise HTTPException(
status_code=403,
@@ -358,7 +361,7 @@ async def import_instance(
if default_folder_name is None:
effective_folder_id = None
else:
default_folder = await default_import_folder(db, default_folder_name, current_user)
default_folder = await default_import_folder(db, default_folder_name, actor)
effective_folder_id = default_folder.id
service = await _build_service(db, provider, current_user, api_key_cloud_owner)
@@ -423,11 +426,8 @@ async def import_instance(
# there as on the manifest-supplied name.
filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
# API-keyed callers carry identity on the key, not in current_user (#1777);
# this collapse stays route-side solely so the library row is attributed
# to the key's owner rather than NULL. Credential identity is resolved
# inside the provider.
cloud_token_user = current_user or api_key_cloud_owner
# Credited to the key's owner for an API key. Credential identity is
# resolved inside the provider.
library_file, was_existing = await save_3mf_bytes_to_library(
db,
file_bytes=download.file_bytes,
@@ -435,7 +435,7 @@ async def import_instance(
folder_id=effective_folder_id,
source_type=provider.source_type,
source_url=source_url,
owner_id=cloud_token_user.id if cloud_token_user else None,
owner_id=actor.id if actor else None,
)
return MakerWorldImportResponse(
+5 -4
View File
@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes.library import save_3mf_bytes_to_library, validate_print_file_upload
from backend.app.api.routes.settings import set_setting
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.auth import ApiKeyActor, RequestActor, RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.library import LibraryFile
@@ -291,7 +291,7 @@ async def get_preview(
# ---- import -------------------------------------------------------------
async def _import_folder_id(db: AsyncSession, folder_id: int | None, user: User | None) -> int | None:
async def _import_folder_id(db: AsyncSession, folder_id: int | None, user: User | ApiKeyActor | None) -> int | None:
"""The chosen folder, or the top-level "Manyfold" folder (created on first use)."""
if folder_id is not None:
# Only into a folder the user may write to (#3201).
@@ -308,6 +308,7 @@ async def import_file(
body: ManyfoldImportRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MANYFOLD_IMPORT),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Download one Manyfold file into the library.
@@ -343,7 +344,7 @@ async def import_file(
filename = _library_filename(file["filename"], file_id)
validate_print_file_upload(filename, data)
folder_id = await _import_folder_id(db, body.folder_id, current_user)
folder_id = await _import_folder_id(db, body.folder_id, actor)
library_file, was_existing = await save_3mf_bytes_to_library(
db,
file_bytes=data,
@@ -351,7 +352,7 @@ async def import_file(
folder_id=folder_id,
source_type=manyfold_provider.source_type,
source_url=source_url,
owner_id=current_user.id if current_user else None,
owner_id=actor.id if actor else None,
)
logger.info(
"[MANYFOLD] Imported %s (model %s, file %s) as library file %s", filename, model_id, file_id, library_file.id
+3 -2
View File
@@ -5,7 +5,7 @@ import logging
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel
from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
from backend.app.core.auth import ApiKeyActor, RequestActor, RequestPrinterScope, RequirePermissionIfAuthEnabled
from backend.app.core.permissions import Permission
from backend.app.core.printer_scope import PrinterScope
from backend.app.models.user import User
@@ -44,6 +44,7 @@ async def get_status(
async def get_printer_status(
user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
printer_scope: PrinterScope = RequestPrinterScope,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Per-printer live classification for the printer cards (#1546).
@@ -54,7 +55,7 @@ async def get_printer_status(
enabled_printers = settings["enabled_printers"]
# Error strings can embed configured URLs (ML API base, external URL), so
# they stay behind settings:read like the rest of the configuration.
can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
can_see_error = actor is None or actor.has_permission(Permission.SETTINGS_READ.value)
per_printer = obico_detection_service.get_per_printer()
if not can_see_error:
# The "error" *class* is not configuration — a printers:read user still
+18 -9
View File
@@ -34,7 +34,13 @@ from sqlalchemy import delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
from backend.app.core.auth import QueueReviewRequired, RequestPrinterScope, RequirePermissionIfAuthEnabled
from backend.app.core.auth import (
ApiKeyActor,
QueueReviewRequired,
RequestActor,
RequestPrinterScope,
RequirePermissionIfAuthEnabled,
)
from backend.app.core.config import settings as app_settings
from backend.app.core.database import async_session, get_db
from backend.app.core.permissions import Permission
@@ -377,15 +383,15 @@ async def _resolve_source(
*,
library_file_id: int | None,
archive_id: int | None,
user: User | None,
user: User | ApiKeyActor | None,
printer_scope: PrinterScope,
) -> tuple[SourceKind, int, str, Path]:
# Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
# source they can see. Without this a READ_OWN caller could reference
# another user's library file / archive by raw id and have it sliced (and,
# via /run, printed) even though a direct GET on that id returned 404.
# Auth-disabled and API-key callers (user is None) keep can_read_all=True —
# no per-row identity, matching the library/archive read helpers.
# Only auth off (user is None) keeps can_read_all=True. An API key arrives
# as its owner's RequestActor, matching the library/archive read helpers.
from backend.app.api.routes.archives import _ensure_archive_visible
from backend.app.api.routes.library import _ensure_library_file_visible
@@ -653,6 +659,7 @@ async def check_eligibility(
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
printer_scope: PrinterScope = RequestPrinterScope,
db: AsyncSession = Depends(get_db),
actor: User | ApiKeyActor | None = RequestActor,
):
pipeline = await _load_pipeline(db, pipeline_id)
printer_scope.ensure(pipeline.target_printer_id)
@@ -660,7 +667,7 @@ async def check_eligibility(
db,
library_file_id=body.source_library_file_id,
archive_id=body.source_archive_id,
user=current_user,
user=actor,
printer_scope=printer_scope,
)
if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
@@ -685,6 +692,7 @@ async def run_pipeline(
printer_scope: PrinterScope = RequestPrinterScope,
review_required: bool = QueueReviewRequired,
db: AsyncSession = Depends(get_db),
actor: User | ApiKeyActor | None = RequestActor,
):
from backend.app.api.routes.settings import get_setting
from backend.app.services.slice_dispatch import slice_dispatch
@@ -693,14 +701,13 @@ async def run_pipeline(
# The pipeline is shared config; running it is limited to what the caller
# may print on (#1727)
printer_scope.ensure(pipeline.target_printer_id)
# ``user=current_user`` deliberately, not the cloud owner below: an API-key
# caller has no per-row identity and must keep can_read_all, the same as
# every other read helper.
# An API key is checked as its owner (RequestActor), like its owner's own
# session; ``creator`` below is a separate question.
src_kind, src_id, src_filename, src_path = await _resolve_source(
db,
library_file_id=body.source_library_file_id,
archive_id=body.source_archive_id,
user=current_user,
user=actor,
printer_scope=printer_scope,
)
@@ -993,6 +1000,7 @@ async def retry_failed(
printer_scope: PrinterScope = RequestPrinterScope,
review_required: bool = QueueReviewRequired,
db: AsyncSession = Depends(get_db),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Create a new run with copies = (failed + cancelled count) from the
parent. Same pipeline, same source. Eligibility re-checked at run time
@@ -1039,6 +1047,7 @@ async def retry_failed(
body,
current_user=current_user,
api_key_cloud_owner=api_key_cloud_owner,
actor=actor,
printer_scope=printer_scope,
review_required=review_required,
db=db,
+49 -31
View File
@@ -15,7 +15,9 @@ from sqlalchemy.orm import selectinload
from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
from backend.app.core.auth import (
ApiKeyActor,
QueueReviewRequired,
RequestActor,
RequestPrinterScope,
RequirePermissionIfAuthEnabled,
RequirePrinterPermissionIfAuthEnabled,
@@ -167,7 +169,7 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
_extract_print_time_from_3mf = extract_print_time_from_3mf
def _assert_can_queue_archive(archive: PrintArchive, current_user: User | None) -> None:
def _assert_can_queue_archive(archive: PrintArchive, current_user: User | ApiKeyActor | None) -> None:
"""Gate turning *archive* into a print. Raises rather than returning a verdict.
Shared by every route that creates queue items from an archive, so a new
@@ -189,6 +191,7 @@ def _assert_can_queue_archive(archive: PrintArchive, current_user: User | None)
allows any archive, REPRINT_OWN allows own only, ownerless archives
require REPRINT_ALL (fail-closed).
"""
# None is auth off. An API key arrives as its RequestActor, never None.
if current_user is None:
return
if not current_user.has_permission(Permission.ARCHIVES_READ_ALL.value) and archive.created_by_id != current_user.id:
@@ -204,8 +207,9 @@ def _assert_can_queue_archive(archive: PrintArchive, current_user: User | None)
)
def _assert_can_queue_library_file(library_file: LibraryFile, current_user: User | None) -> None:
def _assert_can_queue_library_file(library_file: LibraryFile, current_user: User | ApiKeyActor | None) -> None:
"""Gate turning *library_file* into a print — LIBRARY_READ_ALL or ownership."""
# None is auth off. An API key arrives as its RequestActor, never None.
if current_user is None:
return
if (
@@ -274,7 +278,9 @@ async def _is_orders_last_source(db: AsyncSession, item: PrintQueueItem) -> bool
return survivor is None
async def _assert_can_dispatch_batch_sources(db: AsyncSession, batch_id: int, current_user: User | None) -> None:
async def _assert_can_dispatch_batch_sources(
db: AsyncSession, batch_id: int, current_user: User | ApiKeyActor | None
) -> None:
"""Apply the ``POST /queue/`` source-file gates to everything a dispatch would print.
Dispatching clones existing queue items, so without this it would be a
@@ -682,7 +688,7 @@ async def list_queue(
async def _resolve_queue_variants(
db: AsyncSession,
specs: list[QueueVariantCreate],
current_user: User | None,
current_user: User | ApiKeyActor | None,
) -> list[tuple[QueueVariantCreate, LibraryFile, str]]:
"""Validate a cross-model candidate set and pair each file with its model (#671).
@@ -827,6 +833,7 @@ async def add_to_queue(
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
printer_scope: PrinterScope = RequestPrinterScope,
review_required: bool = QueueReviewRequired,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Add an item to the print queue."""
# Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E").
@@ -851,7 +858,7 @@ async def add_to_queue(
raise HTTPException(
400, "Cannot combine variants with archive_id or library_file_id — the variants are the files"
)
variant_specs = await _resolve_queue_variants(db, data.variants, current_user)
variant_specs = await _resolve_queue_variants(db, data.variants, actor)
# Mirror the first candidate onto the item so the queue listing, the SJF
# grouping and the "Any H2S" label have something before a printer is
# picked. Resolution overwrites it with whichever candidate actually runs.
@@ -869,6 +876,8 @@ async def add_to_queue(
if data.printer_id and target_model_norm:
raise HTTPException(400, "Cannot specify both printer_id and target_model")
if target_model_norm:
# The key itself, not its actor: the scheduler holds an any-printer job
# to its creator's printers, and a key may be limited to fewer of them.
ensure_model_target_allowed(current_user, printer_scope)
# Validate printer exists (if assigned)
@@ -895,7 +904,7 @@ async def add_to_queue(
archive = result.scalar_one_or_none()
if not archive:
raise HTTPException(400, "Archive not found")
_assert_can_queue_archive(archive, current_user)
_assert_can_queue_archive(archive, actor)
# Validate library file exists (if provided) and get it for filament extraction
library_file = None
@@ -904,7 +913,7 @@ async def add_to_queue(
library_file = result.scalar_one_or_none()
if not library_file:
raise HTTPException(400, "Library file not found")
_assert_can_queue_library_file(library_file, current_user)
_assert_can_queue_library_file(library_file, actor)
# Bambu SD card is FAT32/exFAT — illegal filename chars would 553 at
# FTP upload time (#1540). Reject at queue time so the user gets the
# actionable error before waiting in queue.
@@ -984,10 +993,10 @@ async def add_to_queue(
if existing_batch.status != "active":
raise HTTPException(400, "Cannot add items to a non-active batch")
if (
current_user is not None
actor is not None
and existing_batch.created_by_id is not None
and existing_batch.created_by_id != current_user.id
and not current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
and existing_batch.created_by_id != actor.id
and not actor.has_permission(Permission.QUEUE_UPDATE_ALL.value)
):
raise HTTPException(404, "Batch not found")
batch = existing_batch
@@ -1021,7 +1030,7 @@ async def add_to_queue(
library_file_id=data.library_file_id,
quantity=quantity,
status="active",
created_by_id=current_user.id if current_user else None,
created_by_id=actor.id if actor else None,
)
db.add(batch)
await db.flush() # Get batch.id before creating items
@@ -1106,7 +1115,7 @@ async def add_to_queue(
db,
cost_center_id=data.cost_center_id,
estimated_cost=trusted_estimated_cost,
current_user=current_user,
current_user=actor,
quantity=quantity,
)
@@ -1195,7 +1204,7 @@ async def add_to_queue(
project_id=data.project_id,
position=start_position + i,
status="pending",
created_by_id=current_user.id if current_user else None,
created_by_id=actor.id if actor else None,
batch_id=batch_id,
print_time_seconds=cached_print_time,
)
@@ -1282,6 +1291,7 @@ async def bulk_update_queue_items(
)
),
printer_scope: PrinterScope = RequestPrinterScope,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Bulk update multiple queue items with the same values.
@@ -1351,7 +1361,7 @@ async def bulk_update_queue_items(
db,
cost_center_id=item_update_data.get("cost_center_id", item.cost_center_id),
estimated_cost=trusted_estimated_cost,
current_user=user,
current_user=actor,
exclude_queue_item_id=item.id,
)
@@ -1400,7 +1410,9 @@ def _validate_plate_targets(
return plates
async def _validate_batch_project(db: AsyncSession, project_id: int | None, current_user: User | None) -> None:
async def _validate_batch_project(
db: AsyncSession, project_id: int | None, current_user: User | ApiKeyActor | None
) -> None:
"""404 on a bogus project id rather than letting the FK blow up as a 500."""
if project_id is None:
return
@@ -1410,7 +1422,7 @@ async def _validate_batch_project(db: AsyncSession, project_id: int | None, curr
async def _load_batch_for_write(
db: AsyncSession, batch_id: int, current_user: User | None, permission: Permission
db: AsyncSession, batch_id: int, current_user: User | ApiKeyActor | None, permission: Permission
) -> PrintBatch:
"""Fetch a batch the caller is allowed to modify, or 404.
@@ -1459,6 +1471,7 @@ async def create_batch(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
printer_scope: PrinterScope = RequestPrinterScope,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Create a batch.
@@ -1478,7 +1491,7 @@ async def create_batch(
raise HTTPException(400, "Batch name is required")
plate_targets = _validate_plate_targets(data.plates)
await _validate_batch_project(db, data.project_id, current_user)
await _validate_batch_project(db, data.project_id, actor)
if data.external_source is not None:
existing = await db.execute(
select(PrintBatch.id).where(
@@ -1495,7 +1508,7 @@ async def create_batch(
library_file_id=data.library_file_id,
quantity=len(data.item_ids) if data.item_ids else 1,
status="active",
created_by_id=current_user.id if current_user else None,
created_by_id=actor.id if actor else None,
project_id=data.project_id,
due_date=data.due_date,
notes=data.notes,
@@ -1538,9 +1551,9 @@ async def create_batch(
if not printer_scope.allows(item.printer_id):
continue
if (
current_user is not None
and item.created_by_id != current_user.id
and not current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
actor is not None
and item.created_by_id != actor.id
and not actor.has_permission(Permission.QUEUE_UPDATE_ALL.value)
):
continue
item.batch_id = batch.id
@@ -1560,6 +1573,7 @@ async def update_batch(
data: PrintBatchUpdate,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
actor: User | ApiKeyActor | None = RequestActor,
):
"""Edit an order's header or its per-plate targets while it runs (#342).
@@ -1569,11 +1583,11 @@ async def update_batch(
explicit action, because silently deleting queued work on a number change
would be a nasty surprise.
"""
batch = await _load_batch_for_write(db, batch_id, current_user, Permission.QUEUE_UPDATE_ALL)
batch = await _load_batch_for_write(db, batch_id, actor, Permission.QUEUE_UPDATE_ALL)
plate_targets = _validate_plate_targets(data.plates)
if data.project_id is not None:
await _validate_batch_project(db, data.project_id, current_user)
await _validate_batch_project(db, data.project_id, actor)
if data.name is not None:
if not data.name.strip():
@@ -1632,6 +1646,7 @@ async def dispatch_batch(
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
printer_scope: PrinterScope = RequestPrinterScope,
review_required: bool = QueueReviewRequired,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Queue the runs this order still owes (#342).
@@ -1640,12 +1655,12 @@ async def dispatch_batch(
overrides and print options the user already chose — and the validation
those went through at creation time.
"""
batch = await _load_batch_for_write(db, batch_id, current_user, Permission.QUEUE_UPDATE_ALL)
batch = await _load_batch_for_write(db, batch_id, actor, Permission.QUEUE_UPDATE_ALL)
if batch.status == "cancelled":
raise HTTPException(400, "Cannot dispatch a cancelled batch")
# Dispatch starts prints, so it must not be a weaker door than POST /queue/.
await _assert_can_dispatch_batch_sources(db, batch.id, current_user)
await _assert_can_dispatch_batch_sources(db, batch.id, actor)
try:
created = await dispatch_remaining(
@@ -1654,7 +1669,7 @@ async def dispatch_batch(
plate_id=data.plate_id,
only_plate=data.only_plate,
limit=data.limit,
created_by_id=current_user.id if current_user else None,
created_by_id=actor.id if actor else None,
)
except BatchDispatchError as exc:
raise HTTPException(400, str(exc)) from exc
@@ -1687,6 +1702,7 @@ async def ungroup_batch(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
printer_scope: PrinterScope = RequestPrinterScope,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Disband a batch: clear batch_id from all members and delete the batch row.
@@ -1698,8 +1714,8 @@ async def ungroup_batch(
if not batch:
raise HTTPException(404, "Batch not found")
can_modify_all = current_user is None or current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
if not can_modify_all and batch.created_by_id != (current_user.id if current_user else None):
can_modify_all = actor is None or actor.has_permission(Permission.QUEUE_UPDATE_ALL.value)
if not can_modify_all and batch.created_by_id != (actor.id if actor else None):
raise HTTPException(404, "Batch not found")
await _ensure_batch_in_scope(db, batch_id, printer_scope)
@@ -1708,7 +1724,7 @@ async def ungroup_batch(
ungrouped = 0
remaining = 0
for item in items:
if not can_modify_all and item.created_by_id != (current_user.id if current_user else None):
if not can_modify_all and item.created_by_id != (actor.id if actor else None):
remaining += 1
continue
item.batch_id = None
@@ -1975,6 +1991,7 @@ async def update_queue_item(
)
),
printer_scope: PrinterScope = RequestPrinterScope,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Update a queue item."""
user, can_modify_all = auth_result
@@ -2133,7 +2150,7 @@ async def update_queue_item(
db,
cost_center_id=update_data.get("cost_center_id", item.cost_center_id),
estimated_cost=trusted_estimated_cost,
current_user=user,
current_user=actor,
exclude_queue_item_id=item.id,
)
@@ -2470,6 +2487,7 @@ async def start_queue_item(
)
),
printer_scope: PrinterScope = RequestPrinterScope,
actor: User | ApiKeyActor | None = RequestActor,
):
"""Manually start a staged (manual_start) queue item.
@@ -2530,7 +2548,7 @@ async def start_queue_item(
db,
cost_center_id=item.cost_center_id,
estimated_cost=item.estimated_cost,
current_user=user,
current_user=actor,
exclude_queue_item_id=item.id,
)
+16 -2
View File
@@ -26,7 +26,12 @@ from backend.app.api.routes.orca_cloud import (
_build_authenticated_service as _build_orca_service,
_load_credentials as _load_orca_credentials,
)
from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled, require_ownership_permission
from backend.app.core.auth import (
RequestPrinterScope,
RequirePermissionIfAuthEnabled,
is_auth_enabled,
require_ownership_permission,
)
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
@@ -132,6 +137,10 @@ async def _fetch_cloud_presets(
"""
if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
return _empty_slots(), "not_authenticated"
# The sign-in stored without a user is the auth-off install's, not one for
# a caller who has none while auth is on.
if user is None and await is_auth_enabled(db):
return _empty_slots(), "not_authenticated"
token, _email, region = await get_stored_token(db, user)
if not token:
@@ -212,6 +221,8 @@ async def _fetch_orca_cloud_presets(
"""
if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
return _empty_slots(), "not_authenticated"
if user is None and await is_auth_enabled(db):
return _empty_slots(), "not_authenticated"
creds = await _load_orca_credentials(db, user)
if not creds.token:
@@ -579,6 +590,7 @@ async def get_preset_values(
slot: str = Query("process", description="Preset slot. Only 'process' is supported today."),
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
) -> dict:
"""Effective values of a preset, with its ``inherits:`` chain flattened.
@@ -610,7 +622,9 @@ async def get_preset_values(
return {"resolved": False, "values": {}, "reason": reason}
try:
profile_json = await resolve_preset_ref(db, current_user, ref, slot)
# A cloud preset resolves as the key's owner for a key with Allow
# Cloud Access, like the listing below.
profile_json = await resolve_preset_ref(db, current_user or api_key_cloud_owner, ref, slot)
except HTTPException:
# A preset the caller can't resolve is not a reason to break the panel;
# the slice itself will report it properly if they go ahead.