feat(storage key rot): add admin panel

This commit is contained in:
Edouard Vanbelle
2026-08-02 00:06:08 +02:00
parent a9d5aae781
commit a58351b7ad
12 changed files with 282 additions and 43 deletions
+16
View File
@@ -538,6 +538,22 @@ export function migrationAction(
return mutate(`/api/admin/storage/migration/${action}`, 'POST', body);
}
/**
* K4 (storage-key-rotation): trigger `storage_rotate` on a specific
* storage entry. Normalises every blob on `<name>` to that entry's
* head-pair format: legacy → v1, plaintext ↔ encrypted, old-key →
* new-key. Fire-and-forget — poll `GET /api/admin/jobs/storage_rotate`
* for status.
*
* Backend: `POST /api/admin/storage/entries/{name}/rotate`
* (`admin_handler::trigger_storage_rotate`). Refuses (400) on unknown
* entry name or when a `storage_rotate` / `storage_migration` run is
* already in flight.
*/
export function rotateStorageEntry(name: string): Promise<void> {
return mutate(`/api/admin/storage/entries/${encodeURIComponent(name)}/rotate`, 'POST', undefined);
}
// verifyMigration + MigrationVerifyResult retired in slice 7 of
// docs/plan/storage-multi-entry.md — the corresponding backend
// endpoint's sample-based check is superseded by
@@ -470,7 +470,13 @@
return name_is_recoverable(job.name);
}
function name_is_recoverable(name: string): boolean {
return name.endsWith('_consistency') || name === 'storage_migration';
// K3 storage-key-rotation adds `storage_rotate` to the recoverable
// tenant set. Same shape as `storage_migration` — walks blobs,
// records findings, supports resume from cursor — so it needs the
// same expand/runs/findings surface.
return (
name.endsWith('_consistency') || name === 'storage_migration' || name === 'storage_rotate'
);
}
// Consistency batch shortcut — top button. Only shown when the
@@ -1041,6 +1041,13 @@
different copy. -->
{#if serverStatus().readonly}
<ReadOnlyBanner variant="maintenance" progress={serverStatus().migration} />
{:else if serverStatus().rotation}
<!-- K4 storage-key-rotation: rotation is running but
`readonly` is false — writes continue as normal.
Distinct banner variant so the copy reads
"background maintenance" rather than "server
frozen". -->
<ReadOnlyBanner variant="rotating" progress={serverStatus().rotation} />
{/if}
{@render children()}
</div>
@@ -1,37 +1,27 @@
<script lang="ts">
/**
* Read-only banner — one component, two variants.
* Read-only banner — one component, three variants.
*
* ## `variant="drive"` (default) — drive-scoped freeze
*
* Rendered at the top of any page whose content lives in (or is scoped
* to) a drive whose `policies.read_only === true`. Members see the
* banner and understand why upload / rename / delete / share
* affordances elsewhere in the app fail with a generic error toast —
* the backend engine gate refuses every non-`Read` permission on
* resources in the drive.
*
* Only `Read` permissions pass; the banner does not need to gate any
* behavior itself. It's pure signage. Backed by
* `docs/plan/drive.md` §8 (`read_only`).
*
* Consumed by:
* - `routes/config/drive/[uuid]/+page.svelte` — always shown when
* the drive being configured is frozen.
* - `routes/files/[...path]/+page.svelte` — shown when the current
* folder's owning drive is frozen (parent looks up drive via
* `drives.findByRootFolderId`/`findById`).
* to) a drive whose `policies.read_only === true`.
*
* ## `variant="maintenance"` — server-wide freeze
*
* Rendered inside `AppShell` above `{children}` when the
* `x-server-status` header (see `middleware::server_status`) says
* the whole server is in read-only mode — typically during a
* storage-backend migration. Optional `progress` lets the banner
* show target + percentage.
* `x-server-status` header says the whole server is in read-only
* mode — typically during a `storage_migration` cutover.
*
* Shape / accent is identical between both variants — the design
* system reads them as the same family. Only the copy differs.
* ## `variant="rotating"` — background key rotation
*
* K4 storage-key-rotation. `storage_rotate` walks blobs in place;
* writes/reads continue normally throughout. Copy makes it clear
* this is a background maintenance banner, not a freeze — the app
* is fully usable.
*
* Shape / accent is identical across variants — the design system
* reads them as the same family. Only the copy differs.
*/
import { t } from '$lib/i18n/index.svelte';
import Icon from '$lib/icons/Icon.svelte';
@@ -47,12 +37,13 @@
/**
* `"drive"` — a specific drive is frozen (default; back-compat
* with pre-migration call sites). `"maintenance"` — the whole
* server is in read-only mode.
* server is in read-only mode. `"rotating"` — a background key
* rotation is running; writes continue.
*/
variant?: 'drive' | 'maintenance';
variant?: 'drive' | 'maintenance' | 'rotating';
/** Drive-name shown in the body (variant="drive" only). */
driveName?: string;
/** Migration progress (variant="maintenance" only). */
/** Migration/rotation progress (variant="maintenance" | "rotating" only). */
progress?: Progress;
}
@@ -61,19 +52,28 @@
<div
class="read-only-banner"
class:read-only-banner--rotating={variant === 'rotating'}
role="region"
aria-label={variant === 'maintenance'
? t('server_status.readonly_banner_aria', 'Server maintenance in progress')
: t('drive.read_only_banner.aria', 'This drive is read-only')}
data-testid={variant === 'maintenance' ? 'server-status-banner' : 'read-only-banner'}
: variant === 'rotating'
? t('server_status.rotating_banner_aria', 'Storage key rotation in progress')
: t('drive.read_only_banner.aria', 'This drive is read-only')}
data-testid={variant === 'maintenance'
? 'server-status-banner'
: variant === 'rotating'
? 'server-status-rotating-banner'
: 'read-only-banner'}
>
<div class="read-only-banner__icon" aria-hidden="true">
<Icon name="lock" />
<Icon name={variant === 'rotating' ? 'key' : 'lock'} />
</div>
<div class="read-only-banner__body">
<strong>
{#if variant === 'maintenance'}
{t('server_status.readonly_title', 'Server maintenance in progress')}
{:else if variant === 'rotating'}
{t('server_status.rotating_title', 'Storage key rotation in progress')}
{:else if driveName}
{t(
'drive.read_only_banner.title_named',
@@ -103,6 +103,24 @@
'Uploads, renames, deletes, and shares are refused temporarily. Reads and downloads work as normal.'
)}
{/if}
{:else if variant === 'rotating'}
{#if progress}
{t(
'server_status.rotating_progress',
{
target: progress.target,
migrated: progress.migrated,
total: progress.total,
percent: progress.percent
},
'Rotating encryption on `{{target}}` — {{percent}}% ({{migrated}} / {{total}} blobs). All operations continue normally; this is a background maintenance task.'
)}
{:else}
{t(
'server_status.rotating_body',
'A background key rotation is normalising storage. All operations continue normally.'
)}
{/if}
{:else}
{t(
'drive.read_only_banner.body',
@@ -143,6 +161,18 @@
font-size: var(--text-lg);
}
/* K4 rotating variant — same shape, info accent (softer than the
default), signalling "background task, no user-facing freeze".
Uses `--color-info` when the palette defines it, falls back to
`--color-accent` otherwise. */
.read-only-banner--rotating {
border-left-color: var(--color-info, var(--color-accent));
}
.read-only-banner--rotating .read-only-banner__icon {
color: var(--color-info, var(--color-accent));
}
.read-only-banner__body {
display: flex;
flex-direction: column;
+29 -12
View File
@@ -15,17 +15,34 @@
*/
/**
* JSON shape emitted in the `x-server-status` header. Optional
* `migration` field is present only while a migration is running.
* Progress snapshot shared by both migration and rotation fields.
* Server-side struct is `ProgressHeader` — see
* `middleware::server_status`.
*/
export interface ProgressStatus {
target: string;
migrated: number;
total: number;
percent: number;
}
/**
* JSON shape emitted in the `x-server-status` header.
*
* * `migration` — present only during a `storage_migration` run;
* engages `readonly = true` (all writes are refused).
* * `rotation` — present only during a `storage_rotate` run (K4
* storage-key-rotation); `readonly` stays false, uploads and
* reads continue normally throughout.
*
* Both can be `undefined` on the same response — that's the steady-
* state "nothing running" case and the header may be omitted
* entirely.
*/
export interface ServerStatus {
readonly: boolean;
migration?: {
target: string;
migrated: number;
total: number;
percent: number;
};
migration?: ProgressStatus;
rotation?: ProgressStatus;
}
const DEFAULT: ServerStatus = { readonly: false };
@@ -48,10 +65,10 @@ export function serverStatus(): ServerStatus {
*/
export function updateFromHeader(rawHeader: string | null): void {
if (rawHeader == null) {
// No header on this response = server not in maintenance
// mode = reset the store to the default so any lingering
// banner disappears. Cheap idempotent write.
if (current.readonly || current.migration) current = DEFAULT;
// No header on this response = nothing running server-side
// = reset the store to the default so any lingering banner
// disappears. Cheap idempotent write.
if (current.readonly || current.migration || current.rotation) current = DEFAULT;
return;
}
try {
@@ -34,6 +34,7 @@
setUserRole,
testOidc,
testStorage,
rotateStorageEntry,
createExternalMount,
deleteExternalMount,
listExternalMounts,
@@ -424,6 +425,27 @@
}
}
// K4: `backend_consistency` — the mirror of `blobs_consistency`.
// Walks the entry's backend and reports blobs physically present
// on it that have no matching row in `storage.blobs` (orphans on
// disk/S3). Meaningful for ANY entry, not just the active one —
// useful for spotting leftover data on a deprecated backend.
async function doStorageConsistency(name: string) {
try {
await triggerJob('backend_consistency', { storage: name });
storageMsg = {
text: t(
'admin.storage_backend_audit_triggered',
{ name },
'backend_consistency triggered for `{{name}}` — watch it on the Jobs tab.'
),
ok: true
};
} catch (e) {
storageMsg = { text: errorMessage(e), ok: false };
}
}
async function doMigrateActivate(name: string) {
if (
!confirm(
@@ -438,6 +460,36 @@
await doMigration('start', name);
}
// K4 storage-key-rotation: normalise every blob on `<name>` to
// that entry's head-pair format. Unlike migration, rotation does
// NOT engage read-only mode — uploads/reads keep working
// throughout. Fire-and-forget; the Jobs tab surfaces progress.
async function doRotateEntry(name: string) {
if (
!confirm(
t(
'admin.storage_rotate_confirm',
{ name },
'Start a background rotation on `{{name}}`? Every existing blob is rewritten under the entry’s head pair (v1 header + head key). All operations continue normally during rotation — no read-only mode. Progress shows in the top banner and on the Jobs tab.'
)
)
)
return;
try {
await rotateStorageEntry(name);
storageMsg = {
text: t(
'admin.storage_rotate_triggered',
{ name },
'Rotation started on `{{name}}` — watch it on the Jobs tab (`storage_rotate`).'
),
ok: true
};
} catch (e) {
storageMsg = { text: errorMessage(e), ok: false };
}
}
// Migration
let migration = $state<MigrationStatus | null>(null);
let migrationTimer: ReturnType<typeof setInterval> | null = null;
@@ -2062,6 +2114,20 @@
<Icon name="check-double" />
{t('admin.storage_audit', 'Blob consistency')}
</button>
<!-- Storage-side consistency (K4): the mirror of Blob
consistency. `blobs_consistency` walks the DB and
checks the backend has each blob; `backend_consistency`
walks the backend and checks the DB has each hash.
Together they close the reference graph. -->
<button
type="button"
class="btn btn-sm btn-secondary entry-card__action-btn"
data-testid={`admin-storage-backend-audit-${entry.name}`}
onclick={() => doStorageConsistency(entry.name)}
>
<Icon name="database" />
{t('admin.storage_backend_audit', 'Storage consistency')}
</button>
{#if !entry.is_active && !migrationInFlight}
<button
type="button"
@@ -2082,6 +2148,47 @@
{t('admin.storage_migrate_activate', 'Migrate & activate')}
</button>
{/if}
<!-- Rotate encryption key (K4 storage-key-rotation).
ACTIVE ENTRY ONLY — `storage.blobs` describes the
active backend; rotating a non-active entry would
produce a `rotation_failed` finding per blob that
isn't there (backend refuses this with a 400 too).
Placeholder slot on non-active cards keeps the
three-button row aligned across the grid. Also
disabled while any migration is in flight — backend
refuses concurrent encryption-touching jobs. -->
{#if entry.is_active}
<button
type="button"
class="btn btn-sm btn-secondary entry-card__action-btn"
disabled={migrationInFlight}
data-testid={`admin-storage-rotate-${entry.name}`}
onclick={() => doRotateEntry(entry.name)}
title={migrationInFlight
? t(
'admin.storage_rotate_disabled_migration',
'Cannot rotate while a migration is in flight.'
)
: t(
'admin.storage_rotate_tooltip',
'Normalise every blob on this entry to the head pair’s format (upgrade legacy blobs, re-encrypt under a new key, etc.).'
)}
>
<Icon name="key" />
{t('admin.storage_rotate', 'Rotate key')}
</button>
{:else}
<button
type="button"
class="btn btn-sm btn-secondary entry-card__action-btn entry-card__action-btn--placeholder"
aria-hidden="true"
tabindex={-1}
disabled
>
<Icon name="key" />
{t('admin.storage_rotate', 'Rotate key')}
</button>
{/if}
</div>
</header>
<dl class="entry-card__grid">
+3 -1
View File
@@ -1120,7 +1120,9 @@
"storage_preset": "Preset",
"storage_size": "Stored",
"storage_tab": "Storage",
"storage_test": "Test connection",
"storage_test": "Test",
"storage_backend_audit": "Storage consistency",
"storage_backend_audit_triggered": "backend_consistency triggered for `{{name}}` — watch it on the Jobs tab.",
"time_day_ago": "{{n}} d ago",
"time_hour_ago": "{{n}} h ago",
"time_just_now": "just now",
+3
View File
@@ -778,6 +778,9 @@
"storage_key_placeholder": "Saisir une nouvelle clé",
"storage_path_style": "Forcer le style de chemin",
"storage_path_style_hint": "Requis pour MinIO et certains services compatibles S3",
"storage_test": "Test",
"storage_backend_audit": "Cohérence du stockage",
"storage_backend_audit_triggered": "backend_consistency lancé pour `{{name}}` — suivez son avancement dans l’onglet Tâches.",
"storage_test_connection": "Tester la connexion",
"storage_test_success": "Connexion réussie",
"storage_test_failure": "Échec de la connexion",
+16
View File
@@ -76,4 +76,20 @@ pub trait JobHandler: Send + Sync {
///
/// See trait-level docs for guidance on when to return Ok vs Err.
async fn run(&self, args: &JobRunArgs) -> JobOutcome;
/// `true` iff this handler persists per-run rows to
/// `jobs.recoverable_runs` (cursor + findings + resume). Surfaced
/// on [`crate::infrastructure::scheduler::registry::JobSummary`]
/// so the admin UI can decide whether the row is expandable to
/// show a run history + findings drawer, without hardcoding a
/// name-based allowlist.
///
/// Default is `false` — Part 1 periodic handlers (`TrashCleanup`,
/// `StorageReconcile`, `GrantCleanup`, `DedupGc`) don't have runs
/// or findings. `RecoverableAdapter` overrides to `true` so every
/// tenant registered via `register_recoverable_job` flips the flag
/// automatically at registration time.
fn is_recoverable(&self) -> bool {
false
}
}
@@ -802,6 +802,14 @@ impl JobHandler for RecoverableAdapter {
async fn run(&self, args: &JobRunArgs) -> JobOutcome {
run_or_resume(self.inner.clone(), self.provider.clone(), args).await
}
fn is_recoverable(&self) -> bool {
// Every tenant registered through `register_recoverable_job` is
// wrapped by this adapter, so this flag flips true for exactly
// the set of jobs whose runs + findings the admin UI should
// let operators drill into. No name-based allowlists needed
// downstream.
true
}
}
// ─── Ergonomics: JobRegistry extension for recoverable jobs ─────────────────
+6
View File
@@ -225,6 +225,7 @@ impl JobRegistry {
last_run_at,
last_outcome,
running: state.current_run_start.is_some(),
recoverable: entry.handler.is_recoverable(),
}
})
.collect()
@@ -288,6 +289,10 @@ pub enum RegisterError {
/// - `running` — true iff the in-flight permit is currently held
/// (either the supervisor tick is in progress or an admin trigger
/// raced in).
/// - `recoverable` — true iff the job persists runs + findings to
/// `jobs.recoverable_runs`. Consumed by the admin UI to decide
/// whether the row is expandable (drawer with run history +
/// findings) and to gate the retention/purge action.
#[derive(Debug, Clone, Serialize)]
pub struct JobSummary {
pub name: String,
@@ -300,6 +305,7 @@ pub struct JobSummary {
#[serde(skip_serializing_if = "Option::is_none")]
pub last_outcome: Option<JobOutcome>,
pub running: bool,
pub recoverable: bool,
}
#[cfg(test)]
@@ -685,6 +685,27 @@ pub async fn trigger_storage_rotate(
)));
}
// Refuse on non-active entry. `storage.blobs` describes what's on
// the ACTIVE backend; walking it against a stale target produces a
// `rotation_failed` finding per missing blob (pure noise) and can't
// actually normalise anything the app reads. The right recipe for
// "normalise a different backend" is: migrate to it (blobs land in
// the head-pair's format on arrival — no rotation needed).
let active = state
.core
.active_backend_name
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if name != active {
return Err(AppError::bad_request(format!(
"storage_rotate refuses non-active entry `{name}` — the DB blob registry \
describes the active entry (`{active}`), so walking it against a stale \
target produces spurious `rotation_failed` findings. Activate `{name}` \
first via `Migrate & activate`, then rotate."
)));
}
// Concurrency guard per plan: at most one encryption-touching
// recoverable run at a time across the whole app. Rotation
// rewrites blobs in place; migration copies + swaps; running