mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 22:21:29 +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.
|
||||
|
||||
@@ -116,12 +116,9 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
|
||||
Permission.PRINTER_SENSOR_HISTORY_READ: "can_read_status",
|
||||
Permission.STATS_READ: "can_read_status",
|
||||
Permission.STATS_FILTER_BY_USER: "can_read_status",
|
||||
# USERS_READ_SLIM grants no data an API key could not already reach (#1894):
|
||||
# for API-keyed requests the permission deps return None as ``current_user``,
|
||||
# so ``_validate_user_filter_permission`` in routes/archives.py short-circuits
|
||||
# and ``?created_by_id=N`` is already honoured for every N. Without a way to
|
||||
# discover the ids, that filter is only addressable by brute force. The slim
|
||||
# listing makes it usable; the full USERS_READ listing (emails, roles, group
|
||||
# USERS_READ_SLIM is ids and usernames only (#1894). It lets a key whose
|
||||
# owner holds stats:filter_by_user address ``?created_by_id=N`` without
|
||||
# guessing ids. The full USERS_READ listing (emails, roles, group
|
||||
# membership, permission sets) stays unmapped = admin-only.
|
||||
Permission.USERS_READ_SLIM: "can_read_status",
|
||||
Permission.SYSTEM_READ: "can_read_status",
|
||||
@@ -491,6 +488,38 @@ def _check_apikey_permissions(
|
||||
raise last_failure
|
||||
|
||||
|
||||
class ApiKeyActor:
|
||||
"""An API key standing in for its owner in a route's own per-row checks.
|
||||
|
||||
Permission dependencies answer a key request with no user, and a route's
|
||||
own checks read no user as "auth is off": they skip the archive, library
|
||||
and cost-center ownership tests a signed-in session faces. Routes that
|
||||
make those tests take this from ``RequestActor`` instead. It holds only
|
||||
the permissions the key may exercise (``apikey_effective_permissions``),
|
||||
so it never exceeds the owner nor the key's scope flags, and it is an
|
||||
administrator, for checks such as printing with any cost center, only when
|
||||
the owner is one. A legacy key without an owner has no ``id``, so it owns
|
||||
nothing and is a member of no cost center.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: APIKey, owner: User | None):
|
||||
self.api_key = api_key
|
||||
self.owner = owner
|
||||
self.is_admin: bool = owner is not None and owner.is_admin
|
||||
self.id: int | None = owner.id if owner is not None else None
|
||||
self.username: str | None = owner.username if owner is not None else None
|
||||
self._permissions = frozenset(apikey_effective_permissions(api_key, owner))
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
return permission in self._permissions
|
||||
|
||||
def has_all_permissions(self, *permissions: str) -> bool:
|
||||
return all(p in self._permissions for p in permissions)
|
||||
|
||||
def has_any_permission(self, *permissions: str) -> bool:
|
||||
return any(p in self._permissions for p in permissions)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopedCaller:
|
||||
"""Who passed a scoped door: an API key, a user, or nobody (auth disabled)."""
|
||||
@@ -2012,6 +2041,41 @@ async def get_queue_review_required(
|
||||
QueueReviewRequired = Depends(get_queue_review_required)
|
||||
|
||||
|
||||
async def get_request_actor(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
||||
) -> User | ApiKeyActor | None:
|
||||
"""FastAPI dependency: who a route's own ownership checks run against.
|
||||
|
||||
The signed-in user, or for an API key an ``ApiKeyActor`` for its owner.
|
||||
None only when auth is off. Declare it after the permission dependency,
|
||||
which has already turned away bad credentials.
|
||||
"""
|
||||
async with async_session() as db:
|
||||
if not await is_auth_enabled(db):
|
||||
return None
|
||||
api_key = await validated_api_key_from_request(credentials, x_api_key)
|
||||
if api_key is not None:
|
||||
return ApiKeyActor(api_key, await resolve_apikey_owner(db, api_key))
|
||||
user = None
|
||||
if credentials is not None:
|
||||
cached = _authenticated_user.get()
|
||||
if cached is not None and cached[0] == credentials.credentials:
|
||||
user = cached[1]
|
||||
else:
|
||||
user = await get_current_user_optional(credentials)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
RequestActor = Depends(get_request_actor)
|
||||
|
||||
|
||||
async def get_media_or_request_printer_scope(
|
||||
token: str | None = None,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
A folder has an owner (``created_by_id``, the user who made it) and can be
|
||||
marked ``shared`` by an admin. A user with ``library:read_all`` (or any
|
||||
caller when auth is off, or an API key) sees every folder. A user with only
|
||||
caller when auth is off) sees every folder. An API key is passed in as its
|
||||
``ApiKeyActor`` and treated as its owner within its scopes. A user with only
|
||||
``library:read_own`` sees:
|
||||
|
||||
- folders they own,
|
||||
@@ -14,7 +15,7 @@ They may write into (upload, extract, move files, create subfolders in)
|
||||
their own folders and shared ones, plus the root. Everything else is hidden:
|
||||
a folder they can't see answers 404, exactly like another user's file.
|
||||
|
||||
A folder made without a user (auth off, an API key) is created shared, so
|
||||
A folder made without a user (auth off, a key without an owner) is created shared, so
|
||||
switching auth on later doesn't hide it. Folders from before #3201 got an
|
||||
owner or the shared flag from the upgrade backfill in ``core/database.py``.
|
||||
"""
|
||||
@@ -33,7 +34,7 @@ from backend.app.models.user import User
|
||||
|
||||
|
||||
def sees_all_folders(user: User | None) -> bool:
|
||||
"""True for ``library:read_all``, and for ``None`` (auth off or an API key)."""
|
||||
"""True for ``library:read_all``, and for ``None`` (auth off)."""
|
||||
return user is None or user.has_permission(Permission.LIBRARY_READ_ALL.value)
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.app.core.auth import is_auth_enabled
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.services.model_providers.base import (
|
||||
ModelProvider,
|
||||
@@ -78,7 +79,12 @@ class MakerWorldProvider(ModelProvider):
|
||||
flag those installs read back on the status endpoints.
|
||||
"""
|
||||
identity = user if user is not None else api_key_owner
|
||||
token, _email, _region = await get_stored_token(db, identity)
|
||||
# Without an identity, the stored sign-in is the auth-off install's.
|
||||
# With auth on (an API key without Allow Cloud Access) there is none.
|
||||
if identity is None and await is_auth_enabled(db):
|
||||
token = None
|
||||
else:
|
||||
token, _email, _region = await get_stored_token(db, identity)
|
||||
user_id = identity.id if identity is not None else None
|
||||
return MakerWorldService(
|
||||
client=client,
|
||||
|
||||
@@ -30,6 +30,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service
|
||||
from backend.app.core.auth import is_auth_enabled
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
from backend.app.models.user import User
|
||||
@@ -111,6 +112,12 @@ async def _resolve_local(db: AsyncSession, ref: PresetRef, slot: str) -> str:
|
||||
return preset.setting
|
||||
|
||||
|
||||
# The sign-in stored without a user belongs to an install with auth off. With
|
||||
# auth on, a caller without a user (an API key without Allow Cloud Access)
|
||||
# has no cloud sign-in of its own and must not borrow that one.
|
||||
_NO_CLOUD_IDENTITY = "{cloud} presets need a signed-in user or an API key with Allow Cloud Access ({slot})"
|
||||
|
||||
|
||||
async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> str:
|
||||
"""Fetch a single cloud preset detail. Permission gate matches the
|
||||
rest of the cloud surface (`CLOUD_AUTH`) so a user with `LIBRARY_UPLOAD`
|
||||
@@ -121,6 +128,8 @@ async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, sl
|
||||
status_code=403,
|
||||
detail=f"Cloud presets require the cloud:auth permission ({slot})",
|
||||
)
|
||||
if user is None and await is_auth_enabled(db):
|
||||
raise HTTPException(status_code=403, detail=_NO_CLOUD_IDENTITY.format(cloud="Bambu Cloud", slot=slot))
|
||||
|
||||
token, _email, region = await get_stored_token(db, user)
|
||||
if not token:
|
||||
@@ -198,6 +207,8 @@ async def _resolve_orca_cloud(db: AsyncSession, user: User | None, ref: PresetRe
|
||||
status_code=403,
|
||||
detail=f"Orca Cloud presets require the orca_cloud:auth permission ({slot})",
|
||||
)
|
||||
if user is None and await is_auth_enabled(db):
|
||||
raise HTTPException(status_code=403, detail=_NO_CLOUD_IDENTITY.format(cloud="Orca Cloud", slot=slot))
|
||||
|
||||
try:
|
||||
svc = await _build_orca_service(db, user)
|
||||
|
||||
@@ -414,3 +414,57 @@ class TestSliceRouteCloudOwnerResolution:
|
||||
db=db_session,
|
||||
)
|
||||
assert owner is None
|
||||
|
||||
|
||||
class TestPresetValuesResolvesAsCloudOwner:
|
||||
"""GET /slicer/preset-values resolves a cloud preset as the key's
|
||||
owner when the key has Allow Cloud Access, like the preset listing does.
|
||||
A key without it has no cloud identity there."""
|
||||
|
||||
async def _resolved_as(self, client: AsyncClient, db: AsyncSession, *, can_access_cloud: bool):
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
await _setup_auth_with_admin(client)
|
||||
owner = await _store_admin_cloud_token(db, "cloudadmin", token="fake-token")
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
db.add(
|
||||
APIKey(
|
||||
name="presets",
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
user_id=owner.id,
|
||||
can_manage_library=True,
|
||||
can_access_cloud=can_access_cloud,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
resolve = AsyncMock(side_effect=HTTPException(status_code=400, detail="stop here"))
|
||||
with patch("backend.app.api.routes.slicer_presets.resolve_preset_ref", resolve):
|
||||
resp = await client.get(
|
||||
"/api/v1/slicer/preset-values",
|
||||
params={"source": "cloud", "id": "PFUS123", "slot": "process"},
|
||||
headers={"X-API-Key": full_key},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["resolved"] is False
|
||||
user = resolve.await_args.args[1]
|
||||
return owner, user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_key_with_cloud_scope_resolves_as_its_owner(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
owner, user = await self._resolved_as(async_client, db_session, can_access_cloud=True)
|
||||
assert user is not None and user.id == owner.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_key_without_cloud_scope_has_no_cloud_identity(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
_owner, user = await self._resolved_as(async_client, db_session, can_access_cloud=False)
|
||||
assert user is None
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
"""An API key acts as its owner (#3256).
|
||||
|
||||
A route's own checks on cost centers, archives, library files and folders run
|
||||
against the key's owner, with the owner's permissions narrowed to the key's
|
||||
scope flags. Where it applies, each case goes through the key and through the
|
||||
owner's session, which must agree. A key made before keys had owners owns
|
||||
nothing and may use no cost center.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.auth import generate_api_key, get_password_hash
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.finance import CostCenter, CostCenterMember
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.models.print_batch import PrintBatch
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
|
||||
PASSWORD = "Ownerpass1!"
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def _set(db_session, key: str, value: str) -> None:
|
||||
row = await db_session.scalar(select(Settings).where(Settings.key == key))
|
||||
if row is None:
|
||||
db_session.add(Settings(key=key, value=value))
|
||||
else:
|
||||
row.value = value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def world(db_session):
|
||||
"""Auth and billing on; a key owner who may queue and reprint only their
|
||||
own archives, another user, and one printer."""
|
||||
await _set(db_session, "auth_enabled", "true")
|
||||
await _set(db_session, "advanced_auth_enabled", "false")
|
||||
await _set(db_session, "billing_enabled", "true")
|
||||
group = Group(
|
||||
name="own-queuers",
|
||||
description="t",
|
||||
permissions=[
|
||||
"queue:create",
|
||||
"queue:read_own",
|
||||
"queue:update_own",
|
||||
"archives:read_own",
|
||||
"archives:reprint_own",
|
||||
"library:read_own",
|
||||
],
|
||||
is_system=False,
|
||||
)
|
||||
db_session.add(group)
|
||||
await db_session.flush()
|
||||
owner = User(username="keyowner", password_hash=get_password_hash(PASSWORD), is_active=True, groups=[group])
|
||||
other = User(username="otheruser", password_hash=get_password_hash(PASSWORD), is_active=True)
|
||||
admin = User(username="adminowner", password_hash=get_password_hash(PASSWORD), role="admin", is_active=True)
|
||||
printer = Printer(
|
||||
name="P", ip_address="192.168.9.9", serial_number="00M00A3256000001", access_code="12345678", model="X1C"
|
||||
)
|
||||
db_session.add_all([owner, other, admin, printer])
|
||||
await db_session.commit()
|
||||
return {"owner": owner, "other": other, "admin": admin, "printer": printer}
|
||||
|
||||
|
||||
async def _archive(db_session, created_by_id: int | None, n: int) -> PrintArchive:
|
||||
archive = PrintArchive(
|
||||
filename=f"a{n}.3mf",
|
||||
print_name=f"a{n}",
|
||||
file_path=f"/tmp/a3256_{n}.3mf", # nosec B108
|
||||
file_size=1,
|
||||
content_hash=f"hash3256_{n}",
|
||||
status="completed",
|
||||
cost=1.25,
|
||||
filament_used_grams=50.0,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
return archive
|
||||
|
||||
|
||||
async def _center(db_session, *, owner_user_id: int | None = None, member_id: int | None = None) -> CostCenter:
|
||||
center = CostCenter(
|
||||
name=f"cc-{owner_user_id}-{member_id}",
|
||||
is_active=True,
|
||||
is_private=owner_user_id is not None,
|
||||
owner_user_id=owner_user_id,
|
||||
)
|
||||
db_session.add(center)
|
||||
await db_session.flush()
|
||||
if member_id is not None:
|
||||
db_session.add(CostCenterMember(cost_center_id=center.id, user_id=member_id, can_print=True))
|
||||
await db_session.commit()
|
||||
await db_session.refresh(center)
|
||||
return center
|
||||
|
||||
|
||||
async def _key(db_session, owner_id: int | None, *, can_queue: bool = True) -> dict[str, str]:
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
db_session.add(
|
||||
APIKey(
|
||||
name="probe",
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
user_id=owner_id,
|
||||
can_queue=can_queue,
|
||||
can_read_status=True,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return {"X-API-Key": full_key}
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, username: str) -> dict[str, str]:
|
||||
response = await client.post("/api/v1/auth/login", json={"username": username, "password": PASSWORD})
|
||||
assert response.status_code == 200, response.text
|
||||
return {"Authorization": f"Bearer {response.json()['access_token']}"}
|
||||
|
||||
|
||||
async def _queue(client: AsyncClient, headers, printer: Printer, archive: PrintArchive, cost_center_id: int | None):
|
||||
return await client.post(
|
||||
"/api/v1/queue/",
|
||||
json={"printer_id": printer.id, "archive_id": archive.id, "cost_center_id": cost_center_id},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
class TestCostCenters:
|
||||
async def test_another_users_private_cost_center_is_refused(self, async_client, db_session, world):
|
||||
archive = await _archive(db_session, world["owner"].id, 1)
|
||||
center = await _center(db_session, owner_user_id=world["other"].id)
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
|
||||
by_key = await _queue(async_client, key, world["printer"], archive, center.id)
|
||||
by_session = await _queue(
|
||||
async_client, await _login(async_client, "keyowner"), world["printer"], archive, center.id
|
||||
)
|
||||
|
||||
assert by_key.status_code == by_session.status_code == 403
|
||||
assert await db_session.scalar(select(PrintQueueItem)) is None
|
||||
|
||||
async def test_a_shared_cost_center_needs_membership(self, async_client, db_session, world):
|
||||
archive = await _archive(db_session, world["owner"].id, 1)
|
||||
center = await _center(db_session, member_id=world["other"].id)
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
|
||||
assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 403
|
||||
|
||||
async def test_the_owners_own_cost_center_works_and_the_item_is_the_owners(self, async_client, db_session, world):
|
||||
archive = await _archive(db_session, world["owner"].id, 1)
|
||||
center = await _center(db_session, owner_user_id=world["owner"].id)
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
|
||||
response = await _queue(async_client, key, world["printer"], archive, center.id)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == response.json()["id"]))
|
||||
assert item.cost_center_id == center.id
|
||||
# Credited to the owner, so the scheduler's check at print start has
|
||||
# someone to check, and the owner sees it under queue:read_own.
|
||||
assert item.created_by_id == world["owner"].id
|
||||
|
||||
async def test_a_cost_center_the_owner_is_a_member_of_works(self, async_client, db_session, world):
|
||||
archive = await _archive(db_session, world["owner"].id, 1)
|
||||
center = await _center(db_session, member_id=world["owner"].id)
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
|
||||
assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 200
|
||||
|
||||
async def test_an_admins_key_may_use_any_cost_center_like_the_admin(self, async_client, db_session, world):
|
||||
archive = await _archive(db_session, world["admin"].id, 1)
|
||||
center = await _center(db_session, owner_user_id=world["other"].id)
|
||||
key = await _key(db_session, world["admin"].id)
|
||||
|
||||
assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 200
|
||||
|
||||
async def test_a_key_without_an_owner_may_use_no_cost_center(self, async_client, db_session, world):
|
||||
archive = await _archive(db_session, None, 1)
|
||||
center = await _center(db_session, member_id=world["owner"].id)
|
||||
key = await _key(db_session, None)
|
||||
|
||||
assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 403
|
||||
|
||||
async def test_moving_an_item_to_a_forbidden_cost_center_is_refused(self, async_client, db_session, world):
|
||||
# PATCH through a key needs the owner to hold queue:update_all.
|
||||
group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
|
||||
group.permissions = [*group.permissions, "queue:update_all"]
|
||||
await db_session.commit()
|
||||
archive = await _archive(db_session, world["owner"].id, 1)
|
||||
allowed = await _center(db_session, owner_user_id=world["owner"].id)
|
||||
forbidden = await _center(db_session, owner_user_id=world["other"].id)
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
created = await _queue(async_client, key, world["printer"], archive, allowed.id)
|
||||
assert created.status_code == 200, created.text
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/queue/{created.json()['id']}", json={"cost_center_id": forbidden.id}, headers=key
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == created.json()["id"]))
|
||||
await db_session.refresh(item)
|
||||
assert item.cost_center_id == allowed.id
|
||||
|
||||
|
||||
class TestListingCostCenters:
|
||||
async def test_a_key_lists_its_owners_cost_centers(self, async_client, db_session, world):
|
||||
mine = await _center(db_session, owner_user_id=world["owner"].id)
|
||||
shared = await _center(db_session, member_id=world["owner"].id)
|
||||
await _center(db_session, owner_user_id=world["other"].id)
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
|
||||
by_key = await async_client.get("/api/v1/finance/cost-centers/mine", headers=key)
|
||||
by_session = await async_client.get(
|
||||
"/api/v1/finance/cost-centers/mine", headers=await _login(async_client, "keyowner")
|
||||
)
|
||||
|
||||
assert by_key.status_code == 200, by_key.text
|
||||
assert {c["id"] for c in by_key.json()} == {mine.id, shared.id}
|
||||
assert by_key.json() == by_session.json()
|
||||
|
||||
async def test_a_key_that_cannot_queue_gets_none(self, async_client, db_session, world):
|
||||
await _center(db_session, owner_user_id=world["owner"].id)
|
||||
key = await _key(db_session, world["owner"].id, can_queue=False)
|
||||
|
||||
assert (await async_client.get("/api/v1/finance/cost-centers/mine", headers=key)).status_code == 403
|
||||
|
||||
async def test_a_key_without_an_owner_has_none(self, async_client, db_session, world):
|
||||
await _center(db_session, owner_user_id=world["owner"].id)
|
||||
key = await _key(db_session, None)
|
||||
|
||||
response = await async_client.get("/api/v1/finance/cost-centers/mine", headers=key)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
class TestSources:
|
||||
async def test_another_users_archive_is_not_found(self, async_client, db_session, world):
|
||||
await _set(db_session, "billing_enabled", "false")
|
||||
archive = await _archive(db_session, world["other"].id, 1)
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
|
||||
by_key = await _queue(async_client, key, world["printer"], archive, None)
|
||||
by_session = await _queue(async_client, await _login(async_client, "keyowner"), world["printer"], archive, None)
|
||||
|
||||
assert by_key.status_code == by_session.status_code == 404
|
||||
|
||||
async def test_another_users_library_file_is_not_found(self, async_client, db_session, world):
|
||||
await _set(db_session, "billing_enabled", "false")
|
||||
rel_path = "archive/library/files/probe_3256.gcode.3mf"
|
||||
abs_path = Path(app_settings.base_dir) / rel_path
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
abs_path.write_bytes(b"probe")
|
||||
library_file = LibraryFile(
|
||||
filename="probe_3256.gcode.3mf",
|
||||
file_path=rel_path,
|
||||
file_size=5,
|
||||
file_type="3mf",
|
||||
created_by_id=world["other"].id,
|
||||
)
|
||||
db_session.add(library_file)
|
||||
await db_session.commit()
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
try:
|
||||
response = await async_client.post(
|
||||
"/api/v1/queue/",
|
||||
json={"printer_id": world["printer"].id, "library_file_id": library_file.id},
|
||||
headers=key,
|
||||
)
|
||||
finally:
|
||||
abs_path.unlink(missing_ok=True)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_the_owners_own_archive_still_queues(self, async_client, db_session, world):
|
||||
await _set(db_session, "billing_enabled", "false")
|
||||
archive = await _archive(db_session, world["owner"].id, 1)
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
|
||||
assert (await _queue(async_client, key, world["printer"], archive, None)).status_code == 200
|
||||
|
||||
|
||||
class TestBatches:
|
||||
async def test_another_users_batch_cannot_be_changed(self, async_client, db_session, world):
|
||||
await _set(db_session, "billing_enabled", "false")
|
||||
batch = PrintBatch(name="theirs", created_by_id=world["other"].id)
|
||||
db_session.add(batch)
|
||||
await db_session.commit()
|
||||
key = await _key(db_session, world["owner"].id)
|
||||
|
||||
update = await async_client.patch(f"/api/v1/queue/batches/{batch.id}", json={"name": "mine"}, headers=key)
|
||||
ungroup = await async_client.post(f"/api/v1/queue/batches/{batch.id}/ungroup", headers=key)
|
||||
|
||||
assert update.status_code == 404
|
||||
assert ungroup.status_code in (403, 404)
|
||||
await db_session.refresh(batch)
|
||||
assert batch.name == "theirs"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def library_world(db_session, world):
|
||||
"""The same owner, who may also upload, read stats and pipelines, and a
|
||||
key with the library and status scopes as well."""
|
||||
await _set(db_session, "billing_enabled", "false")
|
||||
group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
|
||||
group.permissions = [*group.permissions, "library:upload", "stats:read", "pipelines:read"]
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
db_session.add(
|
||||
APIKey(
|
||||
name="library probe",
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
user_id=world["owner"].id,
|
||||
can_queue=True,
|
||||
can_read_status=True,
|
||||
can_manage_library=True,
|
||||
)
|
||||
)
|
||||
theirs = LibraryFolder(name="theirs", created_by_id=world["other"].id, shared=False)
|
||||
db_session.add(theirs)
|
||||
await db_session.commit()
|
||||
return {**world, "key": {"X-API-Key": full_key}, "their_folder": theirs}
|
||||
|
||||
|
||||
async def _their_file(db_session, world, filename: str = "theirs.stl") -> LibraryFile:
|
||||
row = LibraryFile(
|
||||
filename=filename,
|
||||
file_path=f"library/files/{filename}",
|
||||
file_type=filename.rsplit(".", 1)[-1],
|
||||
file_size=1024,
|
||||
created_by_id=world["other"].id,
|
||||
)
|
||||
db_session.add(row)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
_SLICE_BODY = {"printer_preset_id": 1, "process_preset_id": 2, "filament_preset_id": 3}
|
||||
|
||||
|
||||
class TestLibrary:
|
||||
async def test_no_folder_inside_another_users_private_folder(self, async_client, db_session, library_world):
|
||||
body = {"name": "sneaky", "parent_id": library_world["their_folder"].id}
|
||||
|
||||
by_key = await async_client.post("/api/v1/library/folders", json=body, headers=library_world["key"])
|
||||
by_session = await async_client.post(
|
||||
"/api/v1/library/folders", json=body, headers=await _login(async_client, "keyowner")
|
||||
)
|
||||
|
||||
assert by_key.status_code == by_session.status_code == 404
|
||||
|
||||
async def test_a_keys_folder_is_its_owners_and_not_shared(self, async_client, db_session, library_world):
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/folders", json={"name": "mine"}, headers=library_world["key"]
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
folder = await db_session.get(LibraryFolder, response.json()["id"])
|
||||
assert folder.created_by_id == library_world["owner"].id
|
||||
assert folder.shared is False
|
||||
|
||||
async def test_no_upload_into_another_users_private_folder(self, async_client, db_session, library_world):
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/files",
|
||||
params={"folder_id": library_world["their_folder"].id},
|
||||
files={"file": ("cube.stl", b"solid cube\nendsolid cube\n", "application/octet-stream")},
|
||||
headers=library_world["key"],
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_no_combining_another_users_file(self, async_client, db_session, library_world):
|
||||
theirs = await _their_file(db_session, library_world)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/files/combine",
|
||||
json={"items": [{"file_id": theirs.id}], "filename": "mix"},
|
||||
headers=library_world["key"],
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "File not found"
|
||||
|
||||
async def test_no_slicing_another_users_file(self, async_client, db_session, library_world):
|
||||
theirs = await _their_file(db_session, library_world)
|
||||
|
||||
response = await async_client.post(
|
||||
f"/api/v1/library/files/{theirs.id}/slice", json=_SLICE_BODY, headers=library_world["key"]
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "File not found"
|
||||
|
||||
async def test_no_queueing_another_users_file_from_the_library(self, async_client, db_session, library_world):
|
||||
theirs = await _their_file(db_session, library_world, "theirs.gcode.3mf")
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue",
|
||||
json={"file_ids": [theirs.id], "printer_id": library_world["printer"].id},
|
||||
headers=library_world["key"],
|
||||
)
|
||||
|
||||
# The same answer an unknown id gets
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["errors"][0]["error"] == "File not found"
|
||||
assert await db_session.scalar(select(PrintQueueItem)) is None
|
||||
|
||||
|
||||
class TestArchivesAndPipelines:
|
||||
async def test_no_slicing_another_users_archive(self, async_client, db_session, library_world):
|
||||
archive = await _archive(db_session, library_world["other"].id, 1)
|
||||
|
||||
response = await async_client.post(
|
||||
f"/api/v1/archives/{archive.id}/slice", json=_SLICE_BODY, headers=library_world["key"]
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Archive not found"
|
||||
|
||||
async def test_no_per_user_stats_without_the_owners_permission(self, async_client, db_session, library_world):
|
||||
by_key = await async_client.get(
|
||||
"/api/v1/archives/stats",
|
||||
params={"created_by_id": library_world["other"].id},
|
||||
headers=library_world["key"],
|
||||
)
|
||||
by_session = await async_client.get(
|
||||
"/api/v1/archives/stats",
|
||||
params={"created_by_id": library_world["other"].id},
|
||||
headers=await _login(async_client, "keyowner"),
|
||||
)
|
||||
|
||||
assert by_key.status_code == by_session.status_code == 403
|
||||
|
||||
async def test_no_pipeline_on_another_users_file(self, async_client, db_session, library_world):
|
||||
theirs = await _their_file(db_session, library_world)
|
||||
created = await async_client.post(
|
||||
"/api/v1/slicer-pipelines/",
|
||||
json={
|
||||
"name": "Batch",
|
||||
"description": None,
|
||||
"printer_preset": {"source": "local", "id": "1"},
|
||||
"process_preset": {"source": "local", "id": "2"},
|
||||
"filament_presets": [{"source": "local", "id": "3"}],
|
||||
"bed_type": None,
|
||||
},
|
||||
headers=await _login(async_client, "adminowner"),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
|
||||
response = await async_client.post(
|
||||
f"/api/v1/slicer-pipelines/{created.json()['id']}/check-eligibility",
|
||||
json={"source_library_file_id": theirs.id},
|
||||
headers=library_world["key"],
|
||||
)
|
||||
|
||||
# Refused by the ownership check, not later for the missing file
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "File not found"
|
||||
@@ -254,7 +254,7 @@ class TestImportEndpoint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_api_key_without_cloud_scope_still_imports_but_owner_is_none(
|
||||
async def test_api_key_without_cloud_scope_imports_anonymously_for_its_owner(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
"""Fail-closed parity: a key with can_manage_library but NOT
|
||||
@@ -262,9 +262,9 @@ class TestImportEndpoint:
|
||||
the cloud-token resolver returns None, so the service is built
|
||||
without a token. The MakerWorldService itself would 401 on
|
||||
get_profile_download in production — here we just confirm the
|
||||
route doesn't suddenly grant cloud identity from a non-cloud key,
|
||||
and that the library row's owner_id stays NULL when there's no
|
||||
resolved cloud-scoped owner.
|
||||
route doesn't suddenly grant cloud identity from a non-cloud key.
|
||||
The library row still belongs to the key's owner: crediting the file
|
||||
is not a cloud question (#3256).
|
||||
"""
|
||||
await _setup_auth_with_admin(async_client)
|
||||
admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
|
||||
@@ -298,10 +298,9 @@ class TestImportEndpoint:
|
||||
assert jwt_user is None
|
||||
assert key_owner is None
|
||||
|
||||
# And owner_id is NULL because the cloud-scope fence said no.
|
||||
result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
|
||||
saved = result.scalar_one()
|
||||
assert saved.created_by_id is None
|
||||
assert saved.created_by_id == admin.id
|
||||
|
||||
|
||||
class TestJwtPathUnchanged:
|
||||
|
||||
@@ -203,7 +203,7 @@ class TestObicoPrinterStatusNoVerdict:
|
||||
# redaction under test is independent of them.
|
||||
loaded = {"enabled": True, "enabled_printers": None}
|
||||
with patch.object(obico_detection_service, "_load_settings", new=AsyncMock(return_value=loaded)):
|
||||
data = await get_printer_status(user=user, printer_scope=ALL_PRINTERS)
|
||||
data = await get_printer_status(user=user, printer_scope=ALL_PRINTERS, actor=user)
|
||||
entry = data["per_printer"][1]
|
||||
assert entry["class"] == "error"
|
||||
assert entry["error"] is None
|
||||
|
||||
@@ -112,6 +112,35 @@ class TestBuildService:
|
||||
read the caller's stored Bambu Cloud token and wire the rejected-token
|
||||
callback so a 401 invalidates the shared credential app-wide."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auth_off(self):
|
||||
"""These describe the auth-off install, the only one whose sign-in is
|
||||
stored without a user (see the auth-on test below)."""
|
||||
with patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.is_auth_enabled",
|
||||
AsyncMock(return_value=False),
|
||||
):
|
||||
yield
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_auth_on_no_identity_borrows_no_stored_sign_in(self):
|
||||
"""With auth on, a caller without a user (an API key without Allow
|
||||
Cloud Access) gets no token: the sign-in stored without a user is the
|
||||
auth-off install's, and may be left over after auth was turned on."""
|
||||
stored = AsyncMock(return_value=("global-tok", "admin@x.com", "global"))
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.model_providers.makerworld.provider.is_auth_enabled",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch("backend.app.services.model_providers.makerworld.provider.get_stored_token", stored),
|
||||
):
|
||||
svc = await makerworld_provider.build_service(db=AsyncMock(), user=None)
|
||||
|
||||
assert svc._auth_token is None
|
||||
stored.assert_not_awaited()
|
||||
await svc.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seeds_token_and_auth_failure_callback(self):
|
||||
db = AsyncMock()
|
||||
|
||||
@@ -435,3 +435,30 @@ async def test_resolve_preset_ref_dispatches_by_source():
|
||||
db, user, PresetRef(source="standard", id="Some Bundled Name"), slot="printer"
|
||||
)
|
||||
assert json.loads(out)["inherits"] == "Some Bundled Name"
|
||||
|
||||
|
||||
# --- no user while auth is on ----------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"resolve,source,loader",
|
||||
[
|
||||
(preset_resolver._resolve_cloud, "cloud", "get_stored_token"),
|
||||
(preset_resolver._resolve_orca_cloud, "orca_cloud", "_build_orca_service"),
|
||||
],
|
||||
)
|
||||
async def test_no_user_with_auth_on_borrows_no_stored_sign_in(resolve, source, loader):
|
||||
"""The sign-in stored without a user is the auth-off install's, and may be
|
||||
left over after auth was turned on. A caller with no user while auth is
|
||||
on (an API key without Allow Cloud Access) must not slice with it."""
|
||||
load = AsyncMock(return_value=("global-tok", "admin@x.com", "global"))
|
||||
with (
|
||||
patch.object(preset_resolver, "is_auth_enabled", AsyncMock(return_value=True)),
|
||||
patch.object(preset_resolver, loader, load),
|
||||
pytest.raises(HTTPException) as exc,
|
||||
):
|
||||
await resolve(MagicMock(), None, PresetRef(source=source, id="X"), slot="printer")
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
load.assert_not_awaited()
|
||||
|
||||
@@ -904,3 +904,33 @@ class TestListPrinterModels:
|
||||
|
||||
result = sp.list_printer_models()
|
||||
assert result is not PRINTER_MODEL_MAP
|
||||
|
||||
|
||||
class TestNoUserWithAuthOn:
|
||||
"""The sign-in stored without a user is the auth-off install's, and may be
|
||||
left over after auth was turned on. A caller with no user while auth is
|
||||
on (an API key without Allow Cloud Access) must not list its presets."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bambu_cloud(self):
|
||||
sp._cloud_cache.clear()
|
||||
with (
|
||||
patch.object(sp, "is_auth_enabled", AsyncMock(return_value=True)),
|
||||
patch.object(sp, "get_stored_token", AsyncMock(return_value=("global-tok", None, None))) as get_tok,
|
||||
):
|
||||
slots, status = await sp._fetch_cloud_presets(MagicMock(), None)
|
||||
assert status == "not_authenticated"
|
||||
assert slots == {"printer": [], "process": [], "filament": []}
|
||||
get_tok.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orca_cloud(self):
|
||||
sp._orca_cloud_cache.clear()
|
||||
with (
|
||||
patch.object(sp, "is_auth_enabled", AsyncMock(return_value=True)),
|
||||
patch.object(sp, "_load_orca_credentials", AsyncMock()) as load,
|
||||
):
|
||||
slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), None)
|
||||
assert status == "not_authenticated"
|
||||
assert slots == {"printer": [], "process": [], "filament": []}
|
||||
load.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user