bambuddy/backend/app/models/github_backup.py
jmoore-skild 945f50a1ab fix(backup): stop overwrite writing a spool's other tag key (#2656)
Four of the review's smaller items.

E6, the substantive one. tag_uid and tray_uuid are both in the overwrite setattr
loop, so a spool matched on one key got the backup's *other* key written onto it.
Neither column has a unique constraint (models/spool.py, and no unique index in
the migrations), so nothing errors — a duplicate tag simply appears, after which
_find_spool's .scalars().first() is non-deterministic and an AMS tag lookup
resolves to an arbitrary one of the two spools. The same loop could also clear a
tag the user had scanned since the backup was taken, when the backup entry held
None.

_find_spool now reports which key matched, and _guard_tag_overwrite drops a tag
column from the write when the incoming value is empty and the local row has one
(the backup predates the scan, so the local tag is the newer fact) or when
another local spool already holds it. Announced in the tally the way the archive
un-delete case already announces itself, rather than done silently — the
spoolTagKept locale key landed with the rest of the i18n block last commit.

E5. The Restore button is hidden without github:restore. All three endpoints are
gated on it server-side, so the modal 403s on its first preview; offering the
button is offering an action that cannot work. Button only — the card stays
visible, since configuring backups is a separate permission — and hasPermission
returns true with auth off, so a single-user instance is unaffected.

E3. models/github_backup.py: the trigger comment said manual/scheduled; this PR
added a third value.

E4. ha_token_from_env: recommending no change, with the reasoning recorded as a
test rather than left in a review thread. It is built only in the settings GET
response, is absent from AppSettingsUpdate, and is therefore never a Settings
row — it cannot reach a backup, so an allowlist entry would be dead code. Worse,
a name-shaped exception to a belt-and-braces denylist is a live hole: an
attacker-authored settings/app_settings.json could get a *token*-named row
written by choosing that name.

4 unit tests and 1 frontend test that fail against this commit's parent, plus 4
controls: a free tag is still written, an unchanged tag is not reported as kept,
an insert is unaffected, and the button still shows with auth disabled.
2026-08-04 08:57:34 -04:00

69 lines
3.3 KiB
Python

"""GitHub backup configuration and log models."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class GitHubBackupConfig(Base):
"""Configuration for GitHub profile backup."""
__tablename__ = "github_backup_config"
id: Mapped[int] = mapped_column(primary_key=True)
repository_url: Mapped[str] = mapped_column(String(500)) # Full GitHub URL
access_token: Mapped[str] = mapped_column(Text) # Personal Access Token
branch: Mapped[str] = mapped_column(String(100), default="main")
provider: Mapped[str] = mapped_column(String(30), default="github")
allow_insecure_http: Mapped[bool] = mapped_column(Boolean, default=False)
# Schedule configuration
schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
schedule_type: Mapped[str] = mapped_column(String(20), default="daily") # hourly/daily/weekly
schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True) # For future cron support
# What to backup
backup_kprofiles: Mapped[bool] = mapped_column(Boolean, default=True)
backup_cloud_profiles: Mapped[bool] = mapped_column(Boolean, default=True)
backup_settings: Mapped[bool] = mapped_column(Boolean, default=False)
backup_spools: Mapped[bool] = mapped_column(Boolean, default=False)
backup_archives: Mapped[bool] = mapped_column(Boolean, default=False)
# Status tracking
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_backup_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_backup_status: Mapped[str | None] = mapped_column(String(20), nullable=True) # success/failed/skipped
last_backup_message: Mapped[str | None] = mapped_column(Text, nullable=True)
last_backup_commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
next_scheduled_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
logs: Mapped[list["GitHubBackupLog"]] = relationship(back_populates="config", cascade="all, delete-orphan")
class GitHubBackupLog(Base):
"""Log entry for GitHub backup runs."""
__tablename__ = "github_backup_logs"
id: Mapped[int] = mapped_column(primary_key=True)
config_id: Mapped[int] = mapped_column(ForeignKey("github_backup_config.id", ondelete="CASCADE"))
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(20)) # running/success/failed/skipped
trigger: Mapped[str] = mapped_column(String(20)) # manual/scheduled/restore
commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
files_changed: Mapped[int] = mapped_column(Integer, default=0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relationships
config: Mapped["GitHubBackupConfig"] = relationship(back_populates="logs")