fix(permissions): self-heal Administrators to ALL_PERMISSIONS on upgrade + Pipelines runs dashboard polish

Administrators system group sync
- Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
  every permission. Upgrades previously only got what one-off backfill blocks
  in seed_default_groups() explicitly listed (library:purge, archives:purge,
  the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
  enum member added without a matching block silently stayed missing on
  existing admin rows. The most recent gap was printer_sensor_history:read
  (Sensor History charts returned 403 for upgraded admins).
- seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
  startup: append every Permission value that isn't already on the row.
  Additive only -- hand-added custom permissions are preserved.
- The pure-admin one-off backfills (library:purge / archives:purge block,
  the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
  branch of the pipeline backfill) are retired since the sync subsumes
  them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
  orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
  cross-group adders) are untouched.
- Tests: test_administrators_printer_sensor_history_read_backfilled
  (regression for the reported gap),
  test_administrators_sync_covers_every_current_permission (generic
  invariant -- any future new permission lands on admin without needing
  a one-off test), test_administrators_sync_is_additive_only (custom
  permissions preserved). 12/12 backfill-migration + 102/102 broader
  permission tests green; ruff clean.

Pipelines runs dashboard
- PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
  native <select> elements are replaced with a bambu-themed FilterDropdown
  (button trigger, floating menu, optgroup-style headers for the Target
  picker, hover + selected states with a check mark, closes on outside
  click and Escape). Same value/onChange contract -- visual only.
- SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
  reference is stable when the data is stable. Fixes the
  react-hooks/exhaustive-deps warning where the inline fallback returned
  a fresh empty array every render, invalidating both downstream useMemo
  caches (target-options + filtered-pipelines list).
This commit is contained in:
maziggy
2026-06-28 11:18:18 +02:00
parent b5263eb5cd
commit b23cb69a66
29 changed files with 1619 additions and 392 deletions
+106 -6
View File
@@ -30,7 +30,7 @@ from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import desc, func, select
from sqlalchemy import delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
@@ -284,6 +284,16 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
printer_name = p.name if p else None
live_job_status = _compute_job_status(job.status, queue_entry)
# If the job WAS dispatched (had a queue_entry_id) but the entry has
# since been deleted from the queue page, the user's intent was
# cancellation. Otherwise the run would stay forever showing as
# ``queued`` because the persisted job.status hasn't been updated.
if (
job.queue_entry_id is not None
and queue_entry is None
and live_job_status not in ("completed", "failed", "cancelled")
):
live_job_status = "cancelled"
job_live_statuses.append(live_job_status)
job_responses.append(
PipelineJobResponse(
@@ -465,6 +475,16 @@ def _make_orchestration_callable(
logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
return {}
# Honour a cancel that landed between ``POST /run`` returning and
# this background task starting. If the run was cancelled while
# still in ``queued`` we must NOT flip it back to ``slicing`` —
# the operator's intent was to stop, and overwriting status here
# was the bug that left runs stuck at ``dispatching`` after a
# user-side cancel (#1425 PR C bug report).
if run.status == "cancelled":
logger.info("pipeline_run %d was cancelled before slicing started", run_id)
return {}
run.status = "slicing"
run.started_at = datetime.now(timezone.utc)
await session.commit()
@@ -512,6 +532,17 @@ def _make_orchestration_callable(
run.sliced_library_file_id = slice_response.library_file_id
# Re-check cancellation: the slice can take minutes, and the
# operator may have hit Cancel during that window. Refresh from
# the DB rather than trusting our in-memory `run` (the cancel
# route writes via a separate session). When cancelled, don't
# enqueue print queue items — that's the whole point of cancel.
await session.refresh(run)
if run.status == "cancelled":
logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
await session.commit()
return slice_response.model_dump()
# PR C: enqueue N copies per the picked assignment strategy.
assignments = await _pick_assignments(session, pipeline, copies)
@@ -542,9 +573,40 @@ def _make_orchestration_callable(
job.queue_entry_id = queue_item.id
job.assigned_printer_id = printer_id # may be None for max_parallel
job.status = "queued"
# Don't write job.status yet — final cancellation check below
# may flip it to 'cancelled' instead. dispatched_at is fine to
# set unconditionally since the orchestration actually got here.
job.dispatched_at = datetime.now(timezone.utc)
# Final cancellation check before committing 'dispatching'. The
# cancel route writes via a separate session so we have to refresh
# to see the latest. If the cancel landed in this narrow window —
# AFTER the post-slice refresh but BEFORE this commit — the queue
# entries we just created would otherwise pick up and print. Mark
# them + the per-copy jobs cancelled so the user's intent sticks.
await session.refresh(run)
if run.status == "cancelled":
logger.info(
"pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
run_id,
len(jobs),
)
for job in jobs:
if job.queue_entry_id:
qe = (
await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
).scalar_one_or_none()
if qe is not None and qe.status in ("pending", "queued"):
qe.status = "cancelled"
if job.status not in ("completed", "failed", "cancelled"):
job.status = "cancelled"
job.completed_at = datetime.now(timezone.utc)
await session.commit()
await _publish_run_event(session, run)
return slice_response.model_dump()
for job in jobs:
job.status = "queued"
run.status = "dispatching"
await session.commit()
await _publish_run_event(session, run)
@@ -720,13 +782,17 @@ async def list_all_runs(
offset: int = 0,
pipeline_id: int | None = None,
status: str | None = None,
target_printer_id: int | None = None,
target_model_class: str | None = None,
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
db: AsyncSession = Depends(get_db),
):
"""Dashboard list. Newest first; filters on pipeline_id + status. The
`status` filter matches the persisted snapshot, not the live roll-up —
in-progress runs may appear under `dispatching` until the next state
transition writes through."""
"""Dashboard list. Newest first; filters on pipeline_id + status +
target_printer_id + target_model_class. The ``status`` filter matches
the persisted snapshot, not the live roll-up — in-progress runs may
appear under ``dispatching`` until the next state transition writes
through. ``target_*`` filters JOIN to the pipeline so runs whose
pipeline currently points at the printer / class are returned."""
limit = max(1, min(limit, 100))
offset = max(0, offset)
@@ -738,6 +804,15 @@ async def list_all_runs(
if status:
stmt = stmt.where(PipelineRun.status == status)
count_stmt = count_stmt.where(PipelineRun.status == status)
if target_printer_id is not None or target_model_class is not None:
stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
if target_printer_id is not None:
stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
if target_model_class is not None:
stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
total = (await db.execute(count_stmt)).scalar() or 0
@@ -748,6 +823,31 @@ async def list_all_runs(
)
_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
@pipeline_run_router.post("/clear")
async def clear_terminal_runs(
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
db: AsyncSession = Depends(get_db),
):
"""Delete every terminal pipeline run (completed / failed / cancelled /
partial_failure). In-flight runs (queued / slicing / dispatching /
in_progress) are preserved — clearing those mid-flight would lose the
operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
relationship; the linked PrintQueueItem rows stay (they have their own
lifecycle on the queue page)."""
# Count first so the response can report how many got cleared. Done
# under the same session/transaction as the delete so the numbers can't
# drift if another caller races in.
count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
n = (await db.execute(count_stmt)).scalar() or 0
if n > 0:
await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
await db.commit()
return {"deleted": n}
@pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
async def get_run(
run_id: int,
+23 -62
View File
@@ -3345,7 +3345,7 @@ async def seed_default_groups():
from sqlalchemy import select
from backend.app.core.permissions import DEFAULT_GROUPS
from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
from backend.app.models.group import Group
from backend.app.models.user import User
@@ -3498,63 +3498,31 @@ async def seed_default_groups():
group.permissions = perms
await session.commit()
# Backfill library:purge + archives:purge for the Administrators group
# on existing installs. Both permissions were added after Administrators
# was first seeded, so upgrading users miss them even though the default
# config (ALL_PERMISSIONS) includes them for fresh installs.
# Backfill: sync the Administrators system group to ALL_PERMISSIONS.
# Administrators' contract is full access to every feature — fresh
# installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
# = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
# whatever permission set existed when they were first seeded, so a
# newly-added Permission enum member silently leaves admins gated out
# of the feature it controls.
#
# Generalises the previous one-off admin backfills (library:purge,
# archives:purge, the OWN/ALL read-flag set + legacy read flags,
# orca_cloud:auth, printer_sensor_history:read, …): every current
# Permission enum value is appended to the admin group if missing.
# Additive only — never removes a permission an operator added by
# hand. Run AFTER the legacy-rename migration above so the renamed
# OWN/ALL variants land in the group before the sync sees them.
result = await session.execute(select(Group).where(Group.name == "Administrators"))
admin_group = result.scalar_one_or_none()
if admin_group and admin_group.permissions is not None:
perms = list(admin_group.permissions)
added = False
for new_perm in ("library:purge", "archives:purge"):
for new_perm in ALL_PERMISSIONS:
if new_perm not in perms:
perms.append(new_perm)
added = True
logger.info("Added %s to Administrators group (backfill)", new_perm)
if added:
admin_group.permissions = perms
await session.commit()
# Backfill the read flag set for the Administrators group on existing
# installs (maziggy/bambuddy-security #2). Two layers:
#
# (a) New OWN/ALL splits — `archives:read_own` etc. Fresh installs get
# these via ALL_PERMISSIONS; upgrades need the explicit backfill
# so admin's permission set matches a fresh install's.
#
# (b) Legacy `archives:read` / `library:read` / `queue:read`. The
# frontend still gates download / preview UI on these LEGACY
# strings (see ArchivesPage / FileManagerPage), so admin needs
# them retained even though the new API uses the OWN/ALL split.
# The PERMISSION_MIGRATION_ALL map deliberately doesn't rename
# read flags for admin — this backfill ensures they're present
# even if they were stripped by hand or by an older migration.
#
# Also includes orca_cloud:auth for parity with fresh-install
# behaviour (ALL_PERMISSIONS covers it; backfill makes sure an
# admin role that's been customised since seed still has it).
result = await session.execute(select(Group).where(Group.name == "Administrators"))
admin_group = result.scalar_one_or_none()
if admin_group and admin_group.permissions is not None:
perms = list(admin_group.permissions)
added = False
for new_perm in (
"archives:read",
"archives:read_own",
"archives:read_all",
"library:read",
"library:read_own",
"library:read_all",
"queue:read",
"queue:read_own",
"queue:read_all",
"orca_cloud:auth",
):
if new_perm not in perms:
perms.append(new_perm)
added = True
logger.info("Added %s to Administrators group (backfill)", new_perm)
logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
if added:
admin_group.permissions = perms
await session.commit()
@@ -3613,25 +3581,18 @@ async def seed_default_groups():
group.permissions = perms
await session.commit()
# Backfill pipeline permissions (#1425). Pipelines were added after
# initial seeding, so existing groups need them appended:
# - Administrators: all three (matches fresh-install ALL_PERMISSIONS)
# Backfill pipeline permissions (#1425) for non-admin groups.
# Administrators is handled by the ALL_PERMISSIONS sync above.
# - Operators: all three (matches fresh-install DEFAULT_GROUPS)
# - Viewers + any group with library:read_own or settings:read:
# - Any other group with library:read_own or settings:read:
# pipelines:read only
result = await session.execute(select(Group))
for group in result.scalars().all():
if not group.permissions:
if not group.permissions or group.name == "Administrators":
continue
perms = list(group.permissions)
changed = False
if group.name == "Administrators":
for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
if new_perm not in perms:
perms.append(new_perm)
changed = True
logger.info("Added %s to Administrators group (backfill)", new_perm)
elif group.name == "Operators":
if group.name == "Operators":
for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
if new_perm not in perms:
perms.append(new_perm)
@@ -730,6 +730,173 @@ class TestPipelineC:
assert body["parent_run_id"] == parent.id
class TestPolishFollowUp:
"""Polish-pass fixes: dashboard target filters, clear endpoint, and the
deleted-queue-entry → cancelled rollup behaviour."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_dashboard_filters_by_target_printer(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
from backend.app.models.pipeline_run import PipelineRun
printer_a = await printer_factory()
printer_b = await printer_factory()
pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
src = await library_file_factory()
for pipe in (pipe_a, pipe_a, pipe_b):
db_session.add(
PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status="completed",
)
)
await db_session.commit()
resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_dashboard_filters_by_target_model_class(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
from backend.app.models.pipeline_run import PipelineRun
await printer_factory(model="X1C")
await printer_factory(model="P1S")
# Two pipelines, one class-targeting X1C, one P1S.
pipe_x = await pipeline_factory()
await async_client.put(
f"/api/v1/slicer-pipelines/{pipe_x['id']}",
json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
)
pipe_p = await pipeline_factory()
await async_client.put(
f"/api/v1/slicer-pipelines/{pipe_p['id']}",
json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
)
src = await library_file_factory()
for pipe in (pipe_x, pipe_p, pipe_p):
db_session.add(
PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status="completed",
)
)
await db_session.commit()
resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert all(r["target_model_class"] == "P1S" for r in body["runs"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_clear_endpoint_deletes_terminal_runs_only(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
from backend.app.models.pipeline_run import PipelineRun
printer = await printer_factory()
pipe = await pipeline_factory(target_printer_id=printer.id)
src = await library_file_factory()
for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
db_session.add(
PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status=status,
)
)
await db_session.commit()
resp = await async_client.post("/api/v1/pipeline-runs/clear")
assert resp.status_code == 200, resp.text
assert resp.json()["deleted"] == 4 # 4 terminal statuses cleared
# The in-flight rows survive.
survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
assert survivors["total"] == 2
assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleted_queue_entry_rolls_up_as_cancelled(
self,
async_client: AsyncClient,
pipeline_factory,
printer_factory,
library_file_factory,
db_session,
):
"""When the queue entry that a PipelineJob is linked to gets deleted
from the print-queue page, the job's live status should roll up to
``cancelled`` so the run doesn't sit forever showing ``queued`` /
``dispatching``."""
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
printer = await printer_factory()
pipe = await pipeline_factory(target_printer_id=printer.id)
src = await library_file_factory()
# Simulate the state PR C leaves a successful dispatch in: run is
# 'dispatching' and the job has a queue_entry_id pointing at a
# PrintQueueItem that no longer exists.
run = PipelineRun(
pipeline_id=pipe["id"],
source_library_file_id=src.id,
copies=1,
status="dispatching",
)
db_session.add(run)
await db_session.flush()
db_session.add(
PipelineJob(
pipeline_run_id=run.id,
copy_index=0,
queue_entry_id=999999, # Doesn't exist — simulates manual delete from queue.
assigned_printer_id=printer.id,
status="queued",
)
)
await db_session.commit()
await db_session.refresh(run)
resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
assert resp.status_code == 200, resp.text
body = resp.json()
# Job rolled up to cancelled because the queue entry is gone.
assert body["jobs"][0]["status"] == "cancelled"
# Run also rolls up — all jobs cancelled → run reads as cancelled.
assert body["status"] == "cancelled"
class TestCancelTerminal:
@pytest.mark.asyncio
@pytest.mark.integration
@@ -218,6 +218,63 @@ class TestReadPermissionMigration:
perms = await _get_perms("Operators")
assert "orca_cloud:auth" in perms
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_printer_sensor_history_read_backfilled(self, async_client: AsyncClient):
"""Admin without `printer_sensor_history:read` (older custom edit or
a DB seeded before that permission existed) gets it backfilled —
regression for the gap maziggy hit on a live install where the
per-permission admin backfills missed it."""
await seed_default_groups()
async with _database_module.async_session() as session:
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
grp.permissions = [p for p in (grp.permissions or []) if p != "printer_sensor_history:read"]
await session.commit()
await seed_default_groups()
perms = await _get_perms("Administrators")
assert "printer_sensor_history:read" in perms
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_sync_covers_every_current_permission(self, async_client: AsyncClient):
"""Generic invariant: ALL_PERMISSIONS sync ensures every Permission
enum value is present on the Administrators group, no matter what
was stripped pre-backfill. Catches every future "new permission
missing on upgrade" regression without needing a one-off test."""
from backend.app.core.permissions import ALL_PERMISSIONS
await seed_default_groups()
# Wipe the admin group's permission list entirely and force the sync
# to put everything back.
async with _database_module.async_session() as session:
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
grp.permissions = []
await session.commit()
await seed_default_groups()
perms = await _get_perms("Administrators")
missing = [p for p in ALL_PERMISSIONS if p not in perms]
assert not missing, f"Administrators missing permissions after backfill: {missing}"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_sync_is_additive_only(self, async_client: AsyncClient):
"""The sync block must never remove a permission an operator added by
hand — only add missing entries from ALL_PERMISSIONS."""
await seed_default_groups()
async with _database_module.async_session() as session:
grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
grp.permissions = [*(grp.permissions or []), "custom:plugin_permission"]
await session.commit()
await seed_default_groups()
perms = await _get_perms("Administrators")
assert "custom:plugin_permission" in perms
@pytest.mark.asyncio
@pytest.mark.integration
async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):