Skip to content

refactor(backups)!: gate restore on graph type, retire the encryption flag - #1102

Merged
jfrench9 merged 3 commits into
mainfrom
refactor/backup-drop-encryption-flag
Aug 8, 2026
Merged

refactor(backups)!: gate restore on graph type, retire the encryption flag#1102
jfrench9 merged 3 commits into
mainfrom
refactor/backup-drop-encryption-flag

Conversation

@jfrench9

@jfrench9 jfrench9 commented Aug 8, 2026

Copy link
Copy Markdown
Member

What

The backup encryption flag was inert — it performed no application-layer encryption and only toggled two policy gates: encrypted backups could not be downloaded, unencrypted ones could not be restored. Since it defaulted to false, the default backup was silently unrestorable.

Restore availability is a property of the graph, so it's gated there now, via one helper shared by the restore route and the list response:

Graph type download restore
Generic / private repository yes yes
Entity yes no — use materialize
Shared repository yes (quota'd) no

The entity guard also fails closed when the graph row is unresolvable; previously it fell through.

Also in here

  • restore_supported added to BackupListResponse so clients can gate the action without inferring it
  • encryption removed from BackupCreateRequest; encryption_enabled and allow_export removed from BackupResponse
  • Removes a dead .enc key suffix, an is_encrypted/encryption_enabled metadata key mismatch, and an inert encrypted form field on the internal graph_api boundary
  • GRAPH_BACKUP_ENCRYPTION_KEY is no longer read; dropped from config and bootstrap. The existing secret can be deleted separately.
  • SECURITY.md updated to describe the protections actually in force for backups (SSE-AES256 at rest, TLS in transit, short-lived signed URLs)
  • Fixes the admin backups endpoint, which summed a size_bytes attribute that is not a column on GraphBackup and raised on any graph with a backup
  • The encryption_enabled column stays on GraphBackup as the historical record of which backups were once marked encrypted

Coordination

Clients regenerated and published as 1.6.0 (TS #184, Python #177). Frontend in robosystems-app picks up 1.6.0 and reads restore_supported; it carries a fallback so it behaves correctly against an API that predates this change.

Removing response fields is breaking for a consumer that reads them. Verified none do — across robosystems-app, @robosystems/core, roboledger-app, and roboinvestor-app — and released as a minor deliberately rather than carrying dead fields through a deprecation cycle.

Tests

just test-all — 12,309 passed, 23 skipped; ruff, format, basedpyright, cf-lint clean. Adds tests/routers/graphs/test_restore_backup_op.py, the first coverage the restore route has had.

Deploy note

A backup_graph_job enqueued before this deploy and executed after will carry an encryption key that BackupGraphConfig no longer accepts. Backup jobs are short-lived, so the window is small — avoid deploying mid-backup.

The `encryption` flag never encrypted anything. `security/encryption.py`
had no production callers, so the flag only toggled two policy gates:
encrypted backups could not be downloaded, unencrypted ones could not be
restored. Since the default was `encryption=false`, the default backup was
silently unrestorable, and the stated reason for blocking downloads — that
the payload would be undecryptable — did not hold.

Restore availability is a property of the graph, so gate it there:

- Entity graphs are materialized from the extensions database; restore stays
  refused, and the guard now fails closed when the graph row is unresolvable
  instead of falling through.
- Shared repositories stay download-only (403, unchanged).
- Generic and private repository graphs are restorable, and every completed
  backup is downloadable.

`restore_supported` is added to the backup list response so clients can gate
the action without inferring it. `BackupCreateRequest.encryption` and the two
response fields are kept, deprecated and inert, so this rides an SDK minor;
they are due for removal in the next client major.

Objects remain encrypted at rest with S3 SSE-AES256 and are served over TLS
through short-lived signed URLs. SECURITY.md claimed application-layer Fernet
encryption of backups, which was not the case; corrected to describe what
actually protects them. `GRAPH_BACKUP_ENCRYPTION_KEY` is no longer read and is
dropped from the bootstrap; the existing secret can be deleted separately.

Also fixes the admin backups endpoint, which summed a `size_bytes` attribute
that is not a column on `GraphBackup` and raised on any graph with a backup,
and adds the first tests for the restore route.
Giving encryption_enabled and allow_export Pydantic defaults dropped them
out of the OpenAPI required set, widening the generated client types from
boolean to boolean | undefined. The server always sends both, so that is a
breaking read for consumers with nothing gained — it would have forced an
SDK major rather than a minor. Deprecate them in place instead.
Drops `encryption` from BackupCreateRequest and `encryption_enabled` /
`allow_export` from BackupResponse rather than carrying them deprecated.
Nothing reads them: the flag never encrypted anything and no longer gates
download or restore, and `allow_export` was always the inverse of it.

Removing response fields is a compile-break for a typed consumer that
references them, so the client regen is a deliberate, coordinated change
rather than silent drift.

The `encryption_enabled` column stays on GraphBackup. It is the only record
of which backups were once marked encrypted, which is worth keeping even
though nothing acts on it.
@jfrench9
jfrench9 merged commit f050a8f into main Aug 8, 2026
7 checks passed
@jfrench9
jfrench9 deleted the refactor/backup-drop-encryption-flag branch August 8, 2026 18:55
jfrench9 added a commit that referenced this pull request Aug 8, 2026
…on't have

`total_gb_hours` was a storage-billing unit. There is no storage billing:
`storage_overage_gb` is a column nothing ever writes, the billing config
has no storage entries at all, and the frontend's `storage_billing_enabled`
/ `storage_rate_per_gb_per_day` are dead fields the API never returns.
Storage is included in the tier. So the endpoint published a billing
figure, labelled "for billing", for a mechanism that was removed.

This retires the concept rather than the arithmetic. #1095 fixed the
computation two days ago — correctly, but on a premise that was already
false. A correct number nobody should be reading is worse than an obviously
wrong one, because it invites use.

BREAKING: `StorageSummary.total_gb_hours` is removed from
`GET /v1/graphs/{graph_id}/usage`. Both published clients carry it in
their generated models and need a regen; same shape as the backup
encryption-field removal in #1102. No frontend reads it (grepped
robosystems-app).

The time-weighted machinery stays, because it was never really about
GB-hours: `avg_storage_gb` is now computed by `_time_weighted_average_gb`,
weighting each reading by the span since the previous one. A plain mean
would over-count whatever the sensor happened to sample more often, and
the snapshot interval lives in another module and has moved before. The
span cap survives for the same reason — it bounds how far one stale
reading can pull the average after an outage.

Kept, deliberately: `GraphUsage.storage_gb` and the `STORAGE_SNAPSHOT`
rows. Those are the measurement, not the billing unit, and the capacity
work reads them. `storage_overage_gb` also stays for now — dropping a
column wants its own migration.

Tests: the cadence-independence case is rewritten against the average
(same property, still fails a plain mean). The outage test was rewritten
too — it had two readings, where the cap changes nothing and the assertion
passed either way; it now uses three, so capped (4.2) and uncapped (4.9)
differ and it actually tests the cap.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant