feat: export/import del dataset utente (0.7.0 -> 0.7.1) #2

Merged
marcello merged 5 commits from feat/export-import into main 2026-08-24 15:26:56 +00:00
Owner

Aggiunge export/import admin-only del dataset utente per rendere la migrazione un click.

  • Export/Import via API /api/admin/export e /api/admin/import + pannello nella admin UI.
  • Copre users (inclusi i principal MCP svc:*), entity_permissions, google_links.
  • Opzione per includere /data/jwt_secret dietro flag esplicito (cosi i token MCP a TTL lungo sopravvivono al restore).
  • Import con upsert e conferma esplicita prima di sovrascrivere.
  • Test round-trip (tests/test_backup_roundtrip.py) + docs (docs/export-import.md).
  • Bump versione add-on 0.7.0 -> 0.7.1.

5 commit su main.

Aggiunge export/import admin-only del dataset utente per rendere la migrazione un click. - Export/Import via API `/api/admin/export` e `/api/admin/import` + pannello nella admin UI. - Copre `users` (inclusi i principal MCP `svc:*`), `entity_permissions`, `google_links`. - Opzione per includere `/data/jwt_secret` dietro flag esplicito (cosi i token MCP a TTL lungo sopravvivono al restore). - Import con upsert e conferma esplicita prima di sovrascrivere. - Test round-trip (`tests/test_backup_roundtrip.py`) + docs (`docs/export-import.md`). - Bump versione add-on 0.7.0 -> 0.7.1. 5 commit su main.
Three places carried the version by hand — config.yaml, FastAPI(version=),
and the MCP handshake's serverInfo — held together by a "keep in sync"
comment. Now config.py parses `version:` out of config.yaml once at import
and exports APP_VERSION; the other two read it.

Parsed with a regex rather than PyYAML: the image installs from vendored
aarch64 wheels with --no-index, so a YAML parser would mean re-vendoring a
dependency to read one scalar. The Dockerfile now copies config.yaml next
to the package so the file exists at runtime; outside the container it is
found one directory up, in the source checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All the bridge knows lives in the add-on's /data volume — skynet.db plus
jwt_secret — and that volume belongs to the *installed* add-on. Installing
"the same" add-on from another repository (the GitHub -> Forgejo move) is
an uninstall/install, so the volume is gone and there are no migrations to
fall back on. GET /api/admin/export and POST /api/admin/import are the way
across: one JSON file with users, entity_permissions and google_links.

Decisions worth writing down:

- user ids travel in the file and are restored verbatim. Every JWT we mint
  puts str(user.id) in `sub`, so renumbering on restore would silently
  repoint the year-long MCP service token at a different principal. When
  the id is already taken by someone else we do NOT steal it — the user
  comes back under a new id and the conflict list says so.
- children reference their owner by google_subject, never by id, so the
  file stays readable and mergeable. `svc:` service principals are plain
  users and ride along with their permissions.
- google_auth_codes is excluded: those codes live ~60 seconds.
- the JWT secret is only included with include_secret=true, because that
  one string mints a token for any user next to it in the file. Restoring
  it swaps the key in-process too, so pre-migration tokens keep verifying
  without an add-on restart.
- import never deletes. A row on the target that the file omits is counted
  as extra_on_target and left alone; a row that differs is a *conflict*,
  and without overwrite=true the request writes nothing and answers 409
  with the full list. Re-importing the same file is a no-op.

Planning and applying share one transaction on purpose: permissions can
only be matched to an owner after the users are flushed, so the rollback
is what lets a refused import report every conflict at once while still
being a genuine no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both halves go through fetch() rather than a plain <a download> and a
<form>, so the request keeps the same relative path the rest of the panel
uses — Ingress rewrites it and injects X-Ingress-Path, which is the only
thing gating /api/admin.

The export lands as a Blob the browser saves under the filename the server
put in Content-Disposition. Ticking "include the JWT signing secret" asks
for a confirm() first: that file is a credential, and the hint next to the
checkbox says why you might still want it.

A refused import prints the server's conflict list verbatim under the
buttons — it is the list of exactly what "Overwrite" would change, so it
belongs on screen rather than in a 2.5s toast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
25 tests built around the actual migration: populate an install (two
humans, one MCP service principal, permissions, a Google link), export,
drop and recreate the schema the way a reinstall would, import, and assert
the export of the restored database is identical to the file.

Beyond the happy path, the ones that encode a decision:

- restored user ids match, and a token minted before the wipe still drives
  /mcp once the secret comes back — that is the whole point of
  include_secret;
- the secret is absent unless asked for;
- google_auth_codes is not in the payload;
- re-importing the same file reports everything `unchanged` with no flag;
- a locally-edited user, and a locally-edited permission, both come back
  as conflicts in a single 409 with nothing written;
- an id already taken by another user renumbers instead of stealing it;
- extra rows on the target survive the import;
- a foreign schema_version, a random JSON file and a truncated row are all
  refused with a message that names the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/export-import.md documents the file format field by field, the two
things that have to be preserved for tokens to survive a move (the secret
and the user ids), the create/unchanged/updated/extra_on_target verdicts,
and why restoring a secret always needs overwrite=true — a fresh install
has already generated one of its own, so you really are replacing
something.

The last section is the migration recipe itself: update in place to 0.7.1
first (otherwise the export endpoint does not exist yet), export with the
secret, write down the add-on options — they live in the Supervisor, not
in /data, and are not part of the export — then swap repository and
import.

CLAUDE.md gets the design rationale next to the MCP one, and two more
entries under "cose da NON fare": no implicit deletes on import, and no
secret in the export by default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Soltea-org/skynet-ha!2
No description provided.