feat(admin): expose KMS backup and restore behind explicit guards (#5579)

* feat(kms): add backup and restore admin API

Wires the merged KMS backup contract, Local export and Local restore into
the admin API: export a sealed bundle, run a zero-write restore preflight,
execute a confirmed restore, roll an interrupted restore back, and report
subsystem readiness.

- Dedicated kms:Backup / kms:Restore actions, recorded in the admin route
  matrix. Neither is reachable through any other KMS action.
- Restore requires two independent confirmations: an echo of the bundle
  manifest's backup id, and an explicitly named conflict policy (the
  default never writes).
- The backup KEK comes from the environment and is refused when it reuses
  a secret of the configured backend, compared both as the literal value
  and as raw key bytes.
- No endpoint accepts a path: bundles are addressed by a validated name
  under a configured root, and the restore target is always the server's
  own configured key directory.
- Bundles now carry a sanitized configuration artifact built as an
  allowlist projection, so a future backend credential field cannot leak
  into a bundle by default. Restore verifies it and never applies it.
- Audit entries go through the existing KMS admin wiring and carry
  identifiers only.

* test(kms): pin the backup admin API gates

Fixes the test KEK to a real 32-byte value and drives the export refusal
from the configured backend rather than from the handle that happens to
be available, so a Local handle cannot export on behalf of a backend
whose material RustFS does not own.
This commit is contained in:
Zhengchao An
2026-08-01 22:31:25 +08:00
committed by GitHub
parent 02aa383598
commit f2d09d1426
14 changed files with 1847 additions and 1 deletions
+1
View File
@@ -288,6 +288,7 @@ atoi = { workspace = true }
atomic_enum = { workspace = true }
async_zip = { workspace = true, default-features = false, features = ["tokio", "deflate"] }
base64 = { workspace = true }
zeroize = { workspace = true }
hmac = { workspace = true }
sha2 = { workspace = true }
rsa = { workspace = true, features = ["sha2"] }
+2 -1
View File
@@ -14,7 +14,7 @@
//! KMS admin handlers for HTTP API
use super::{kms_dynamic, kms_key_lifecycle, kms_keys, kms_management};
use super::{kms_backup, kms_dynamic, kms_key_lifecycle, kms_keys, kms_management};
use crate::admin::router::{AdminOperation, S3Router};
pub fn register_kms_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
@@ -22,5 +22,6 @@ pub fn register_kms_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<(
kms_dynamic::register_kms_dynamic_route(r)?;
kms_keys::register_kms_key_route(r)?;
kms_key_lifecycle::register_kms_key_lifecycle_route(r)?;
kms_backup::register_kms_backup_route(r)?;
Ok(())
}
+32
View File
@@ -98,6 +98,16 @@ pub(super) enum KmsAdminOperation {
Start,
/// Stop of the KMS service.
Stop,
/// Export of a backup bundle.
Backup,
/// Readiness lookup for the backup subsystem.
BackupStatus,
/// Zero-write restore preflight.
RestoreDryRun,
/// Execution of a restore.
Restore,
/// Roll-back of an interrupted restore.
RestoreAbort,
}
impl KmsAdminOperation {
@@ -109,6 +119,11 @@ impl KmsAdminOperation {
Self::Reconfigure => "Reconfigure",
Self::Start => "Start",
Self::Stop => "Stop",
Self::Backup => "Backup",
Self::BackupStatus => "BackupStatus",
Self::RestoreDryRun => "RestoreDryRun",
Self::Restore => "Restore",
Self::RestoreAbort => "RestoreAbort",
}
}
@@ -121,6 +136,18 @@ impl KmsAdminOperation {
Self::Configure | Self::Reconfigure => EventName::KmsServiceConfigured,
Self::Start => EventName::KmsServiceStarted,
Self::Stop => EventName::KmsServiceStopped,
// A backup reads the material of every key, and a restore
// preflight reads a bundle without touching the target: both are
// key access. The `kmsOperation` tag distinguishes them, and the
// event-name space is a fixed 64-bit mask that is nearly full, so
// these reuse the existing access event rather than claiming two
// more bits of it.
Self::Backup | Self::BackupStatus | Self::RestoreDryRun => EventName::KmsKeyAccessed,
// A restore publishes key material into the target directory.
Self::Restore => EventName::KmsKeyCreated,
// Aborting an interrupted restore removes the material a partial
// cutover had already published.
Self::RestoreAbort => EventName::KmsKeyDeleted,
}
}
}
@@ -545,6 +572,11 @@ mod tests {
KmsAdminOperation::Reconfigure,
KmsAdminOperation::Start,
KmsAdminOperation::Stop,
KmsAdminOperation::Backup,
KmsAdminOperation::BackupStatus,
KmsAdminOperation::RestoreDryRun,
KmsAdminOperation::Restore,
KmsAdminOperation::RestoreAbort,
] {
assert!(
operation.event().is_kms(),
File diff suppressed because it is too large Load Diff
+1
View File
@@ -33,6 +33,7 @@ pub mod inspect_archive;
pub mod is_admin;
pub mod kms;
pub mod kms_audit;
pub mod kms_backup;
pub mod kms_dynamic;
pub mod kms_key_lifecycle;
pub mod kms_keys;
+49
View File
@@ -54,6 +54,7 @@ const HEALTH_INFO: AdminActionRef = AdminActionRef::new("HealthInfoAdminAction")
const IMPORT_BUCKET_METADATA: AdminActionRef = AdminActionRef::new("ImportBucketMetadataAction");
const IMPORT_IAM: AdminActionRef = AdminActionRef::new("ImportIAMAction");
const INSPECT_DATA: AdminActionRef = AdminActionRef::new("InspectDataAction");
const KMS_BACKUP: AdminActionRef = AdminActionRef::new("kms:Backup");
const KMS_CLEAR_CACHE: AdminActionRef = AdminActionRef::new("kms:ClearCache");
const KMS_CONFIGURE: AdminActionRef = AdminActionRef::new("kms:Configure");
const KMS_DELETE_KEY: AdminActionRef = AdminActionRef::new("kms:DeleteKey");
@@ -62,6 +63,7 @@ const KMS_DISABLE_KEY: AdminActionRef = AdminActionRef::new("kms:DisableKey");
const KMS_ENABLE_KEY: AdminActionRef = AdminActionRef::new("kms:EnableKey");
const KMS_GENERATE_DATA_KEY: AdminActionRef = AdminActionRef::new("kms:GenerateDataKey");
const KMS_LIST_KEYS: AdminActionRef = AdminActionRef::new("kms:ListKeys");
const KMS_RESTORE: AdminActionRef = AdminActionRef::new("kms:Restore");
const KMS_ROTATE_KEY: AdminActionRef = AdminActionRef::new("kms:RotateKey");
const KMS_SERVICE_CONTROL: AdminActionRef = AdminActionRef::new("kms:ServiceControl");
const LIST_GROUPS: AdminActionRef = AdminActionRef::new("ListGroupsAdminAction");
@@ -788,6 +790,18 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
RouteRiskLevel::High,
),
admin(HttpMethod::Post, "/rustfs/admin/v3/kms/keys/rotate", KMS_ROTATE_KEY, RouteRiskLevel::High),
// Backup and restore act on the material of every key at once, so they
// carry their own actions rather than reusing any per-key one.
admin(HttpMethod::Get, "/rustfs/admin/v3/kms/backup", KMS_BACKUP, RouteRiskLevel::Sensitive),
admin(HttpMethod::Post, "/rustfs/admin/v3/kms/backup", KMS_BACKUP, RouteRiskLevel::High),
admin(
HttpMethod::Post,
"/rustfs/admin/v3/kms/restore/dry-run",
KMS_RESTORE,
RouteRiskLevel::Sensitive,
),
admin(HttpMethod::Post, "/rustfs/admin/v3/kms/restore", KMS_RESTORE, RouteRiskLevel::High),
admin(HttpMethod::Post, "/rustfs/admin/v3/kms/restore/abort", KMS_RESTORE, RouteRiskLevel::High),
public(
HttpMethod::Get,
"/rustfs/admin/v3/oidc/providers",
@@ -1826,6 +1840,41 @@ mod tests {
assert_action(HttpMethod::Delete, "/rustfs/admin/v3/kms/keys/delete", KMS_DELETE_KEY);
assert_action(HttpMethod::Post, "/rustfs/admin/v3/kms/keys/cancel-deletion", KMS_DELETE_KEY);
assert_action(HttpMethod::Get, "/rustfs/admin/v3/kms/keys/{key_id}", KMS_DESCRIBE_KEY);
assert_action(HttpMethod::Post, "/rustfs/admin/v3/kms/backup", KMS_BACKUP);
assert_action(HttpMethod::Get, "/rustfs/admin/v3/kms/backup", KMS_BACKUP);
assert_action(HttpMethod::Post, "/rustfs/admin/v3/kms/restore", KMS_RESTORE);
assert_action(HttpMethod::Post, "/rustfs/admin/v3/kms/restore/dry-run", KMS_RESTORE);
assert_action(HttpMethod::Post, "/rustfs/admin/v3/kms/restore/abort", KMS_RESTORE);
}
/// Backup and restore expose the whole key inventory at once, so no other
/// KMS action may reach them: holding `kms:Configure` or a per-key action
/// must not be enough.
#[test]
fn route_policy_isolates_backup_and_restore_from_other_kms_actions() {
for (method, path) in [
(HttpMethod::Get, "/rustfs/admin/v3/kms/backup"),
(HttpMethod::Post, "/rustfs/admin/v3/kms/backup"),
(HttpMethod::Post, "/rustfs/admin/v3/kms/restore"),
(HttpMethod::Post, "/rustfs/admin/v3/kms/restore/dry-run"),
(HttpMethod::Post, "/rustfs/admin/v3/kms/restore/abort"),
] {
for action in [
SERVER_INFO,
KMS_CONFIGURE,
KMS_DESCRIBE_KEY,
KMS_LIST_KEYS,
KMS_SERVICE_CONTROL,
KMS_DELETE_KEY,
] {
assert_not_action(method, path, action);
}
}
// Backup and restore are separate privileges: neither implies the
// other.
assert_not_action(HttpMethod::Post, "/rustfs/admin/v3/kms/backup", KMS_RESTORE);
assert_not_action(HttpMethod::Post, "/rustfs/admin/v3/kms/restore", KMS_BACKUP);
}
#[test]
@@ -343,6 +343,11 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
admin_route(Method::POST, "/v3/kms/keys/enable"),
admin_route(Method::POST, "/v3/kms/keys/disable"),
admin_route(Method::POST, "/v3/kms/keys/rotate"),
admin_route(Method::GET, "/v3/kms/backup"),
admin_route(Method::POST, "/v3/kms/backup"),
admin_route(Method::POST, "/v3/kms/restore/dry-run"),
admin_route(Method::POST, "/v3/kms/restore"),
admin_route(Method::POST, "/v3/kms/restore/abort"),
admin_route(Method::GET, "/v3/oidc/providers"),
admin_route_sample(Method::GET, "/v3/oidc/authorize/{provider_id}", "/v3/oidc/authorize/default"),
admin_route_sample(Method::GET, "/v3/oidc/callback/{provider_id}", "/v3/oidc/callback/default"),