mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 17:50:12 +02:00
Security hardening (maziggy/bambuddy-security #10)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user