fix(backup): carry the owner across, or restored archives are invisible (#2656)

Neither _collect_archives nor _restore_archives touched created_by_id, so every
restored archive row landed NULL. That column is not attribution, it is what the
access check runs on: _ensure_archive_visible (api/routes/archives.py) fails
closed on NULL — a 404 for any caller without archives:read_all — and the list
paths filter created_by_id == user.id. On a multi-user instance the tally
therefore reported archives restored while the person who owns them could
neither list nor open them.

Same shape as the deleted_at fix, and the same remedy: the collector records the
key next to deleted_at, the restore mirrors the printer_id/project_id pattern
exactly — one hoisted select(User.id), a membership test per row, an unknown id
coerced to None rather than failing the row, and one de-duplicated note. It is in
the overwrite setattr loop too, so overwrite keeps meaning "make local match the
backup". Additive on the backup side, so older backups still restore; they just
cannot know the owner.

Clearing the id is not silent-safe, so the note says what it costs: those
archives are visible only to users with archives:read_all until an admin
reassigns them.

Caveat recorded in a comment and raised in the PR, not decided here: this is the
one place the module reuses a raw backup id, against its own rule. Validating it
means a *stale* id clears rather than pointing somewhere wrong, but a live id
belonging to a different person on a different instance would still collide.
Collecting username and resolving on that would close it.

6 unit tests and 1 integration test that all fail against the parent commit,
plus 2 controls that pass either way — a backup with no created_by_id key still
restores, and a second operator still gets a 404.
This commit is contained in:
jmoore-skild
2026-08-04 08:57:33 -04:00
parent ca93d4cf44
commit cfa82bfcfb
4 changed files with 257 additions and 0 deletions
+7
View File
@@ -869,6 +869,13 @@ class GitHubBackupService:
# deleted_at is what lets a restore put them back the way they
# were instead of resurrecting them as visible archives.
"deleted_at": str(a.deleted_at) if a.deleted_at else None,
# Who owns the archive, for the same reason deleted_at is here:
# it is not decoration, it is what the access check runs on.
# _ensure_archive_visible (api/routes/archives.py) fails closed on
# a NULL created_by_id and the list paths filter on it, so a
# restored row without it is invisible to everyone but an admin —
# while the restore reports it restored.
"created_by_id": a.created_by_id,
}
archive_list.append(archive_data)
+26
View File
@@ -48,6 +48,7 @@ from backend.app.models.project import Project
from backend.app.models.settings import Settings
from backend.app.models.spool import Spool
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.models.user import User
from backend.app.schemas.github_backup import RestoreCategory
from backend.app.services.git_providers.factory import get_provider_backend
from backend.app.services.printer_manager import printer_manager
@@ -760,6 +761,19 @@ class GitHubRestoreService:
valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
valid_projects = set((await db.execute(select(Project.id))).scalars().all())
# Ownership decides visibility, not just attribution: an archive with a
# NULL created_by_id is a 404 to every caller without archives:read_all
# (_ensure_archive_visible fails closed on it) and never appears in the
# ownership-scoped list queries. Hoisted like the two above.
#
# Note this is the one place a raw backup id is reused, against the
# module's own rule at the top of the file. Users have no natural key the
# backup carries today, and the id is validated rather than trusted, so a
# *stale* id clears instead of pointing somewhere wrong. What it cannot
# catch is a live id belonging to a different person on a different
# instance. Collecting username and resolving on that would close it;
# raised with the maintainer rather than decided here.
valid_users = set((await db.execute(select(User.id))).scalars().all())
# Only metadata is backed up, never the 3MF/thumbnail bytes, and
# print_archives.file_path is NOT NULL — so inserted rows get an empty
@@ -817,8 +831,20 @@ class GitHubRestoreService:
if project_id is not None and project_id not in valid_projects:
tally.note("Some archives referenced projects that no longer exist — link cleared")
project_id = None
created_by_id = entry.get("created_by_id")
if created_by_id is not None and created_by_id not in valid_users:
# Coerced rather than failing the row: the archive is still worth
# having, and an admin can reassign it. Said out loud because a
# cleared owner is not silent-safe — the archive becomes visible
# only to archives:read_all until someone does.
tally.note(
"Some archives referenced users that no longer exist — owner cleared, so they are "
"visible only to users with the archives:read_all permission until an admin reassigns them"
)
created_by_id = None
fields["printer_id"] = printer_id
fields["project_id"] = project_id
fields["created_by_id"] = created_by_id
if existing is not None:
if old_id is not None:
@@ -4,6 +4,9 @@ from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
@pytest.fixture(autouse=True)
@@ -276,6 +279,83 @@ class TestStatusExposesRestoreState:
assert response.json()["restore_running"] is False
class TestRestoredArchivesAreVisibleToTheirOwner(TestOwnershipPermissionsSetup):
"""The archive-ownership blocker, proved through the route that enforces it.
``_ensure_archive_visible`` fails closed on a NULL ``created_by_id`` — 404 for
any caller without ``archives:read_all`` — so before the collector and the
restore carried the column across, a multi-user instance got archives the
tally called restored and their owner could not open.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_owning_non_admin_can_open_a_restored_archive(
self, async_client: AsyncClient, auth_setup, db_session
):
from backend.app.models.archive import PrintArchive
from backend.app.services.github_restore import _CategoryTally, github_restore_service
owner_id = auth_setup["operator_user"]["id"]
payload = {
"archives": [
{
"id": 77,
"filename": "benchy.3mf",
"file_size": 2048,
"content_hash": "abc123",
"print_name": "Benchy",
"started_at": "2026-03-01 10:00:00",
"created_at": "2026-03-01 10:00:00",
"created_by_id": owner_id,
}
]
}
await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
await db_session.commit()
restored = (await db_session.execute(select(PrintArchive))).scalar_one()
assert restored.id != 77, "the backup's primary key must not be reused"
response = await async_client.get(
f"/api/v1/archives/{restored.id}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 200, "the owner cannot see their own restored archive"
assert response.json()["print_name"] == "Benchy"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_different_operator_still_cannot(self, async_client: AsyncClient, auth_setup, db_session):
"""Control: carrying the owner across must not widen who can read it."""
from backend.app.models.archive import PrintArchive
from backend.app.services.github_restore import _CategoryTally, github_restore_service
payload = {
"archives": [
{
"id": 77,
"filename": "benchy.3mf",
"file_size": 2048,
"content_hash": "abc123",
"started_at": "2026-03-01 10:00:00",
"created_by_id": auth_setup["operator_user"]["id"],
}
]
}
await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
await db_session.commit()
restored = (await db_session.execute(select(PrintArchive))).scalar_one()
response = await async_client.get(
f"/api/v1/archives/{restored.id}",
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
)
assert response.status_code == 404
class TestRestoreDoesNotOpenTheMetricsEndpoint:
"""The companion-credential rule, proved against the endpoint it protects.
+144
View File
@@ -17,6 +17,7 @@ from backend.app.models.archive import PrintArchive
from backend.app.models.settings import Settings
from backend.app.models.spool import Spool
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.models.user import User
from backend.app.schemas.github_backup import GitHubRestoreRequest, RestoreCategory
from backend.app.services.github_restore import (
_COMPANION_CREDENTIAL_ENV,
@@ -1302,6 +1303,149 @@ class TestSoftDeletedArchiveRoundTrip:
assert row.deleted_at == deleted_at, "a deleted archive must not come back visible"
class TestRestoredArchiveOwnership:
"""A restored archive without an owner is invisible to the person who owns it.
``created_by_id`` is not attribution, it is the column the access check runs
on: ``_ensure_archive_visible`` fails closed on NULL (404 for any caller
without ``archives:read_all``) and the list paths filter
``created_by_id == user.id``. So on a multi-user instance the tally reported
archives restored while their owner could neither list nor open them.
"""
def _entry(self, **overrides):
entry = {
"id": 77,
"filename": "benchy.3mf",
"file_size": 2048,
"content_hash": "abc123",
"started_at": "2026-03-01 10:00:00",
"created_at": "2026-03-01 10:00:00",
}
entry.update(overrides)
return entry
async def _user(self, db, username="alice"):
user = User(username=username, role="operator")
db.add(user)
await db.flush()
return user
@pytest.mark.asyncio
async def test_owner_is_carried_across(self, db_session):
user = await self._user(db_session)
tally = _CategoryTally()
await _service()._restore_archives(
db_session, {"archives": [self._entry(created_by_id=user.id)]}, False, tally, {}
)
await db_session.commit()
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.created_by_id == user.id
assert not any("owner cleared" in note for note in tally.notes)
@pytest.mark.asyncio
async def test_an_unknown_owner_is_cleared_with_a_note_not_failed(self, db_session):
"""The archive is still worth having; an admin can reassign it."""
tally = _CategoryTally()
await _service()._restore_archives(
db_session, {"archives": [self._entry(created_by_id=4242)]}, False, tally, {}
)
await db_session.commit()
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.created_by_id is None
assert tally.restored == 1 and tally.failed == 0
assert any("owner cleared" in note and "archives:read_all" in note for note in tally.notes)
@pytest.mark.asyncio
async def test_the_owner_note_is_emitted_once_for_many_rows(self, db_session):
tally = _CategoryTally()
archives = [
self._entry(id=1, content_hash="h1", filename="a.3mf", created_by_id=4242),
self._entry(id=2, content_hash="h2", filename="b.3mf", created_by_id=4243),
]
await _service()._restore_archives(db_session, {"archives": archives}, False, tally, {})
await db_session.commit()
assert sum(1 for note in tally.notes if "owner cleared" in note) == 1
@pytest.mark.asyncio
async def test_a_backup_without_the_key_still_restores(self, db_session):
"""Backups taken before the collector recorded it just can't know the owner."""
tally = _CategoryTally()
await _service()._restore_archives(db_session, {"archives": [self._entry()]}, False, tally, {})
await db_session.commit()
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.created_by_id is None
assert not any("owner cleared" in note for note in tally.notes)
@pytest.mark.asyncio
async def test_overwrite_makes_the_local_owner_match_the_backup(self, db_session):
alice = await self._user(db_session, "alice")
bob = await self._user(db_session, "bob")
db_session.add(
PrintArchive(
filename="benchy.3mf",
file_path="/data/benchy.3mf",
file_size=2048,
content_hash="abc123",
started_at=datetime(2026, 3, 1, 10, 0, 0),
created_by_id=bob.id,
)
)
await db_session.commit()
await _service()._restore_archives(
db_session, {"archives": [self._entry(created_by_id=alice.id)]}, True, _CategoryTally(), {}
)
await db_session.commit()
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.created_by_id == alice.id
@pytest.mark.asyncio
async def test_owner_survives_collect_then_restore(self, db_session):
"""Both halves, because each looks harmless alone.
The collector never wrote the key, so there was nothing for the restore
to carry across even once it wanted to.
"""
from backend.app.services.github_backup import github_backup_service
user = await self._user(db_session)
db_session.add(
PrintArchive(
filename="owned.3mf",
file_path="",
file_size=1024,
content_hash="hash-owned",
started_at=datetime(2026, 3, 1, 10, 0, 0),
created_by_id=user.id,
)
)
await db_session.commit()
files: dict = {}
await github_backup_service._collect_archives(db_session, files)
payload = files[ARCHIVES_PATH]
assert payload["archives"][0]["created_by_id"] == user.id
await db_session.execute(PrintArchive.__table__.delete())
await db_session.commit()
await _service()._restore_archives(db_session, payload, False, _CategoryTally(), {})
await db_session.commit()
row = (await db_session.execute(select(PrintArchive))).scalar_one()
assert row.created_by_id == user.id, "a restored archive its owner cannot see is not restored"
class TestCategoryPathMapping:
def setup_method(self):
self.service = _service()