mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-03 11:57:43 +00:00
b457c6abcc
Contract-only module for KMS backup/restore (no handler or backend wiring): versioned manifest schema with completeness marker and sealed digest, the (backend, at-rest protection) responsibility matrix, typed fail-closed errors, and the zero-write restore dry-run report. Fields whose shape depends on in-flight contracts are reserved and reject data in format version 1.
234 lines
10 KiB
Rust
234 lines
10 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Backup responsibility matrix: what a RustFS backup bundle owns per backend.
|
|
//!
|
|
//! The matrix is two-dimensional on purpose: responsibility is a function of
|
|
//! the backend *and* its at-rest protection state, not of the backend alone.
|
|
//! This keeps the schema stable when a backend changes protection direction —
|
|
//! switching Vault KV2 between storage-only and Transit-wrapped operation
|
|
//! selects a different existing row instead of changing the contract.
|
|
//!
|
|
//! These enums are backup-domain contract types. Once the backlog#1571
|
|
//! capability-discovery contract lands, the discovery surface is expected to
|
|
//! converge on (or map onto) the states defined here.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Backend discriminant recorded in a backup manifest.
|
|
///
|
|
/// Wire names are aligned with [`crate::config::BackendConfig`] and
|
|
/// [`crate::config::KmsBackend`] (including the legacy `Vault` alias) so that
|
|
/// a manifest and a persisted KMS configuration never disagree about how the
|
|
/// same backend is spelled.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum BackupBackendKind {
|
|
/// Local file-based backend.
|
|
Local,
|
|
/// Vault KV v2 storage backend.
|
|
#[serde(rename = "VaultKV2", alias = "Vault")]
|
|
VaultKv2,
|
|
/// Vault Transit backend.
|
|
VaultTransit,
|
|
/// Static single-key backend.
|
|
Static,
|
|
}
|
|
|
|
/// At-rest protection state of master key material, as observed at snapshot
|
|
/// time.
|
|
///
|
|
/// The first three states mirror the Local backend's on-disk protection
|
|
/// marker (`StoredKeyProtection` in `backends/local.rs`, kebab-case wire
|
|
/// names). The remaining states describe the non-local backends.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum AtRestProtection {
|
|
/// Local key files AEAD-encrypted under the Argon2id-derived master key.
|
|
EncryptedMasterKey,
|
|
/// Local development-only plaintext key files. Such files must never
|
|
/// enter a bundle as-is; bundle artifacts are always re-wrapped under the
|
|
/// backup KEK.
|
|
PlaintextDevOnly,
|
|
/// Pre-beta.9 local key files without a protection marker; the effective
|
|
/// mode is resolved at read time. Treated like [`Self::PlaintextDevOnly`]
|
|
/// for bundling purposes: re-wrap is mandatory.
|
|
LegacyUnspecified,
|
|
/// Vault KV2 as currently shipped: material confidentiality relies on
|
|
/// Vault ACLs, KV2 at-rest encryption, and TLS only (the backend reports
|
|
/// `at_rest_protection = "vault-kv2-acl"`); RustFS applies no
|
|
/// cryptographic wrapping of its own.
|
|
StorageOnly,
|
|
/// Vault KV2 with material wrapped by Vault Transit before storage. Not
|
|
/// produced by any current backend; the row exists so a future direction
|
|
/// change selects a state instead of changing the schema.
|
|
TransitWrapped,
|
|
/// Vault Transit: the cryptographic root lives in Vault and is not
|
|
/// exportable. RustFS can only ever own metadata and references.
|
|
ExternalNonExportable,
|
|
/// Static backend: the secret is delivered externally at startup and
|
|
/// RustFS persists no key material at all.
|
|
ExternalSecretDelivery,
|
|
}
|
|
|
|
/// What a RustFS backup bundle is responsible for, per (backend, protection)
|
|
/// combination.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum BackupResponsibility {
|
|
/// The bundle carries the complete recoverable state: encrypted key
|
|
/// material for every version, salt, metadata, and configuration.
|
|
///
|
|
/// Restore precondition for the Local backend: the operator re-supplies
|
|
/// the master key out of band. The master key itself is outside the
|
|
/// backup domain; the manifest stores at most an opaque verifier.
|
|
FullMaterial,
|
|
/// The bundle carries only non-sensitive references and verification
|
|
/// information. The source of truth is external secret delivery and the
|
|
/// operator re-provides the secret during restore. Embedding the secret
|
|
/// itself in a bundle is forbidden.
|
|
ReferenceOnly,
|
|
/// The bundle carries RustFS-side metadata, configuration references and
|
|
/// verification data, while the cryptographic root is protected by the
|
|
/// external system's native snapshot/disaster-recovery flow (Vault/HSM).
|
|
/// Restore must re-establish the external trust root first.
|
|
MetadataPlusExternalRoot,
|
|
}
|
|
|
|
impl BackupResponsibility {
|
|
/// Resolve the responsibility matrix for one (backend, protection) cell.
|
|
///
|
|
/// Returns `None` for combinations that no supported deployment can
|
|
/// produce; manifests declaring such a combination are rejected as
|
|
/// corrupted. This function is total and the unit tests anchor every
|
|
/// cell, so any change to the matrix is a deliberate contract change.
|
|
pub fn for_backend(backend: BackupBackendKind, protection: AtRestProtection) -> Option<Self> {
|
|
use AtRestProtection::*;
|
|
use BackupBackendKind::*;
|
|
|
|
match (backend, protection) {
|
|
(Local, EncryptedMasterKey | PlaintextDevOnly | LegacyUnspecified) => Some(Self::FullMaterial),
|
|
(Local, _) => None,
|
|
// Storage-only KV2 offers no external cryptographic root, so the
|
|
// bundle must own the material (re-wrapped under the backup KEK).
|
|
(VaultKv2, StorageOnly) => Some(Self::FullMaterial),
|
|
(VaultKv2, TransitWrapped) => Some(Self::MetadataPlusExternalRoot),
|
|
(VaultKv2, _) => None,
|
|
(VaultTransit, ExternalNonExportable) => Some(Self::MetadataPlusExternalRoot),
|
|
(VaultTransit, _) => None,
|
|
(Static, ExternalSecretDelivery) => Some(Self::ReferenceOnly),
|
|
(Static, _) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::KmsBackend;
|
|
|
|
fn json<T: Serialize>(value: &T) -> String {
|
|
serde_json::to_string(value).expect("serialization should succeed")
|
|
}
|
|
|
|
#[test]
|
|
fn backend_kind_wire_names_match_kms_backend() {
|
|
let pairs = [
|
|
(BackupBackendKind::Local, KmsBackend::Local),
|
|
(BackupBackendKind::VaultKv2, KmsBackend::VaultKv2),
|
|
(BackupBackendKind::VaultTransit, KmsBackend::VaultTransit),
|
|
(BackupBackendKind::Static, KmsBackend::Static),
|
|
];
|
|
for (backup_kind, config_kind) in pairs {
|
|
assert_eq!(json(&backup_kind), json(&config_kind), "wire name drifted for {backup_kind:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn backend_kind_accepts_legacy_vault_alias() {
|
|
let decoded: BackupBackendKind = serde_json::from_str("\"Vault\"").expect("legacy alias should decode");
|
|
assert_eq!(decoded, BackupBackendKind::VaultKv2);
|
|
}
|
|
|
|
#[test]
|
|
fn responsibility_matrix_is_anchored_cell_by_cell() {
|
|
use AtRestProtection::*;
|
|
use BackupBackendKind::*;
|
|
use BackupResponsibility::*;
|
|
|
|
// Every (backend, protection) cell, exhaustively. Changing any row is
|
|
// a contract change and must be made here consciously.
|
|
let matrix = [
|
|
(Local, EncryptedMasterKey, Some(FullMaterial)),
|
|
(Local, PlaintextDevOnly, Some(FullMaterial)),
|
|
(Local, LegacyUnspecified, Some(FullMaterial)),
|
|
(Local, StorageOnly, None),
|
|
(Local, TransitWrapped, None),
|
|
(Local, ExternalNonExportable, None),
|
|
(Local, ExternalSecretDelivery, None),
|
|
(VaultKv2, EncryptedMasterKey, None),
|
|
(VaultKv2, PlaintextDevOnly, None),
|
|
(VaultKv2, LegacyUnspecified, None),
|
|
(VaultKv2, StorageOnly, Some(FullMaterial)),
|
|
(VaultKv2, TransitWrapped, Some(MetadataPlusExternalRoot)),
|
|
(VaultKv2, ExternalNonExportable, None),
|
|
(VaultKv2, ExternalSecretDelivery, None),
|
|
(VaultTransit, EncryptedMasterKey, None),
|
|
(VaultTransit, PlaintextDevOnly, None),
|
|
(VaultTransit, LegacyUnspecified, None),
|
|
(VaultTransit, StorageOnly, None),
|
|
(VaultTransit, TransitWrapped, None),
|
|
(VaultTransit, ExternalNonExportable, Some(MetadataPlusExternalRoot)),
|
|
(VaultTransit, ExternalSecretDelivery, None),
|
|
(Static, EncryptedMasterKey, None),
|
|
(Static, PlaintextDevOnly, None),
|
|
(Static, LegacyUnspecified, None),
|
|
(Static, StorageOnly, None),
|
|
(Static, TransitWrapped, None),
|
|
(Static, ExternalNonExportable, None),
|
|
(Static, ExternalSecretDelivery, Some(ReferenceOnly)),
|
|
];
|
|
assert_eq!(matrix.len(), 28, "matrix must stay exhaustive: 4 backends x 7 protection states");
|
|
for (backend, protection, expected) in matrix {
|
|
assert_eq!(
|
|
BackupResponsibility::for_backend(backend, protection),
|
|
expected,
|
|
"matrix cell drifted for ({backend:?}, {protection:?})"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn local_protection_wire_names_match_stored_key_protection() {
|
|
use crate::backends::local::StoredKeyProtection;
|
|
|
|
// The manifest must record exactly the marker values the Local
|
|
// backend writes to disk, or a restore could misread protection.
|
|
let pairs = [
|
|
(AtRestProtection::EncryptedMasterKey, StoredKeyProtection::EncryptedMasterKey),
|
|
(AtRestProtection::PlaintextDevOnly, StoredKeyProtection::PlaintextDevOnly),
|
|
(AtRestProtection::LegacyUnspecified, StoredKeyProtection::LegacyUnspecified),
|
|
];
|
|
for (backup_state, stored_state) in pairs {
|
|
assert_eq!(json(&backup_state), json(&stored_state), "wire name drifted for {backup_state:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn responsibility_wire_names_are_frozen() {
|
|
assert_eq!(json(&BackupResponsibility::FullMaterial), "\"full-material\"");
|
|
assert_eq!(json(&BackupResponsibility::ReferenceOnly), "\"reference-only\"");
|
|
assert_eq!(json(&BackupResponsibility::MetadataPlusExternalRoot), "\"metadata-plus-external-root\"");
|
|
}
|
|
}
|