mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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).
294 lines
12 KiB
Python
294 lines
12 KiB
Python
"""Migration tests for maziggy/bambuddy-security #2 — read permission OWN/ALL backfill.
|
|
|
|
Pre-fix, ARCHIVES_READ / LIBRARY_READ / QUEUE_READ were flat "read all" flags.
|
|
Post-fix they split into OWN/ALL. The migration in seed_default_groups must:
|
|
|
|
1. Rename legacy `archives:read` etc to `archives:read_all` on Administrators
|
|
and to `archives:read_own` on every other role (fail-closed default).
|
|
2. Backfill `_own` AND `_all` variants for the Administrators group on upgrade
|
|
so an upgraded install matches a fresh install's permission set.
|
|
3. Backfill `_own` variants for Operators and Viewers so they keep read access
|
|
even if their stored row didn't carry the legacy flag.
|
|
|
|
These regressions are the failure shape Maziggy hit on a live upgrade — the
|
|
admin role ended up missing queue:read_own AND queue:read after migration.
|
|
"""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.core import database as _database_module
|
|
from backend.app.core.database import seed_default_groups
|
|
from backend.app.models.group import Group
|
|
|
|
_READ_FLAGS = frozenset(
|
|
{
|
|
"archives:read",
|
|
"archives:read_own",
|
|
"archives:read_all",
|
|
"library:read",
|
|
"library:read_own",
|
|
"library:read_all",
|
|
"queue:read",
|
|
"queue:read_own",
|
|
"queue:read_all",
|
|
}
|
|
)
|
|
|
|
|
|
async def _strip_and_set(group_name: str, extra: list[str] | None = None) -> None:
|
|
"""Strip every read flag from ``group_name`` then add ``extra`` flags.
|
|
|
|
Simulates a pre-migration state where the group either had only the
|
|
legacy flat permission (set ``extra=['archives:read']``) or no read
|
|
permission at all (set ``extra=None``).
|
|
"""
|
|
async with _database_module.async_session() as session:
|
|
grp = (await session.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
|
|
assert grp is not None, f"group {group_name} not pre-seeded"
|
|
stripped = [p for p in (grp.permissions or []) if p not in _READ_FLAGS]
|
|
stripped.extend(extra or [])
|
|
grp.permissions = stripped
|
|
await session.commit()
|
|
|
|
|
|
async def _get_perms(group_name: str) -> set[str]:
|
|
async with _database_module.async_session() as session:
|
|
grp = (await session.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
|
|
assert grp is not None
|
|
return set(grp.permissions or [])
|
|
|
|
|
|
# Note: ``async_client`` is depended upon (even though unused) so pytest-asyncio
|
|
# uses the same event loop the conftest fixture uses for async_session(). Without
|
|
# it, calling ``async_session()`` twice in one test trips an asyncpg
|
|
# "got Future attached to a different loop" RuntimeError.
|
|
|
|
|
|
class TestReadPermissionMigration:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_legacy_archives_read_renamed_to_all_for_administrators(self, async_client: AsyncClient):
|
|
"""Existing Administrators group with legacy `archives:read` → gets
|
|
`archives:read_all` after seed_default_groups runs, and gets the
|
|
`_own` companion backfilled too."""
|
|
await seed_default_groups()
|
|
await _strip_and_set("Administrators", extra=["archives:read"])
|
|
|
|
await seed_default_groups()
|
|
|
|
perms = await _get_perms("Administrators")
|
|
# Rename happened: legacy renamed to _all
|
|
assert "archives:read_all" in perms
|
|
# Backfill also added _own so fresh install and upgraded install match
|
|
assert "archives:read_own" in perms
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_administrators_backfill_adds_all_six_read_flags(self, async_client: AsyncClient):
|
|
"""Even with NO legacy flags present, Administrators ends up with both
|
|
OWN and ALL variants for archives / library / queue after the backfill
|
|
pass. This is the case Maziggy hit — admin missing `queue:read_own`
|
|
after upgrade."""
|
|
await seed_default_groups()
|
|
await _strip_and_set("Administrators")
|
|
|
|
await seed_default_groups()
|
|
|
|
perms = await _get_perms("Administrators")
|
|
for needed in (
|
|
"archives:read_own",
|
|
"archives:read_all",
|
|
"library:read_own",
|
|
"library:read_all",
|
|
"queue:read_own",
|
|
"queue:read_all",
|
|
):
|
|
assert needed in perms, f"{needed} must be backfilled for Administrators"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operators_backfill_adds_own_read_flags(self, async_client: AsyncClient):
|
|
"""Operators with no read flags get the _OWN variants backfilled
|
|
(fail-closed — no _ALL)."""
|
|
await seed_default_groups()
|
|
await _strip_and_set("Operators")
|
|
|
|
await seed_default_groups()
|
|
|
|
perms = await _get_perms("Operators")
|
|
assert "archives:read_own" in perms
|
|
assert "library:read_own" in perms
|
|
assert "queue:read_own" in perms
|
|
assert "archives:read_all" not in perms
|
|
assert "library:read_all" not in perms
|
|
assert "queue:read_all" not in perms
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operators_legacy_archives_read_renamed_to_own(self, async_client: AsyncClient):
|
|
"""Pre-PR Operators with legacy `archives:read` get the _OWN rename
|
|
(fail-closed — close the IDOR, the operator can re-request _ALL via
|
|
admin if cross-user visibility is genuinely needed)."""
|
|
await seed_default_groups()
|
|
await _strip_and_set("Operators", extra=["archives:read"])
|
|
|
|
await seed_default_groups()
|
|
|
|
perms = await _get_perms("Operators")
|
|
assert "archives:read_own" in perms
|
|
assert "archives:read_all" not in perms
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_administrators_legacy_archives_read_retained(self, async_client: AsyncClient):
|
|
"""Admin keeps the LEGACY `archives:read` flag — the frontend gates
|
|
download / preview UI on it (ArchivesPage / FileManagerPage), and
|
|
removing it on rename was leaving admin with no visible download
|
|
buttons after upgrade. The new API gates use the _ALL variant which
|
|
the backfill also ensures is present."""
|
|
await seed_default_groups()
|
|
await _strip_and_set("Administrators", extra=["archives:read"])
|
|
|
|
await seed_default_groups()
|
|
|
|
perms = await _get_perms("Administrators")
|
|
# Both the legacy flag (for the UI) and the _all variant (for the API)
|
|
# must coexist on admin.
|
|
assert "archives:read" in perms
|
|
assert "archives:read_all" in perms
|
|
assert "archives:read_own" in perms
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_administrators_backfill_adds_legacy_read_flags(self, async_client: AsyncClient):
|
|
"""Admin with NO read flags at all (hand-edited or stripped role) ends
|
|
up with the legacy `archives:read` / `queue:read` / `library:read`
|
|
backfilled — so the UI gates work — alongside the OWN/ALL split."""
|
|
await seed_default_groups()
|
|
await _strip_and_set("Administrators")
|
|
|
|
await seed_default_groups()
|
|
|
|
perms = await _get_perms("Administrators")
|
|
for needed in (
|
|
"archives:read",
|
|
"library:read",
|
|
"queue:read",
|
|
"archives:read_own",
|
|
"archives:read_all",
|
|
"library:read_own",
|
|
"library:read_all",
|
|
"queue:read_own",
|
|
"queue:read_all",
|
|
):
|
|
assert needed in perms, f"{needed} must be backfilled for Administrators"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_administrators_orca_cloud_auth_backfilled(self, async_client: AsyncClient):
|
|
"""Admin without `orca_cloud:auth` (older custom edit) gets it
|
|
backfilled — matches the fresh-install default."""
|
|
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 != "orca_cloud:auth"]
|
|
await session.commit()
|
|
|
|
await seed_default_groups()
|
|
|
|
perms = await _get_perms("Administrators")
|
|
assert "orca_cloud:auth" in perms
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operators_orca_cloud_auth_backfilled(self, async_client: AsyncClient):
|
|
"""Operators on upgraded installs get `orca_cloud:auth` backfilled
|
|
(the new default — needed for the Slice modal's Orca Cloud preset
|
|
picker)."""
|
|
await seed_default_groups()
|
|
async with _database_module.async_session() as session:
|
|
grp = (await session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
|
|
grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
|
|
await session.commit()
|
|
|
|
await seed_default_groups()
|
|
|
|
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):
|
|
"""Viewers stay read-only — orca_cloud:auth is not added by the
|
|
backfill (matches the fresh-install Viewers bootstrap, which
|
|
intentionally excludes cloud-auth permissions)."""
|
|
await seed_default_groups()
|
|
async with _database_module.async_session() as session:
|
|
grp = (await session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
|
|
grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
|
|
await session.commit()
|
|
|
|
await seed_default_groups()
|
|
|
|
perms = await _get_perms("Viewers")
|
|
assert "orca_cloud:auth" not in perms
|