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)