feat(storage key rot): add admin panel
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ─────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user