// 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. //! What still points at a KMS key, as far as the server can prove it. //! //! # This report never says a key is unused //! //! There is deliberately no `in_use`, no `unreferenced`, and no //! `safe_to_delete`: the report states which sources were consulted //! ([`ReferenceCoverage`]), how exhaustively they could be read //! ([`ReferenceCompleteness`]), and what was found. An empty //! [`KeyImpactReport::references`] therefore means "nothing was found in the //! scanned sources", never "nothing references this key" — object envelopes //! written under a key are not, and cannot cheaply be, enumerated here. //! Collapsing that distinction into a boolean would hand callers a green //! checkmark backed by an unscanned half of the problem, so the distinction //! lives in the type rather than in prose a UI can skip. //! //! # It may only ever add a reason to refuse //! //! A report is an input to refusing destruction, never to permitting it. //! [`KeyImpactReport::blocks_destruction`] is phrased so its `false` case //! carries no authority: it means this report found no reason to refuse, not //! that any other gate has been satisfied. The deletion worker's own //! [`crate::DeletionReferenceChecker`] stays the gate that decides whether //! expired material is destroyed. use serde::{Deserialize, Serialize}; /// A place where references to a key can live. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ReferenceScope { /// A bucket's default server-side encryption configuration. BucketDefaultEncryption, /// The KMS service's configured default key. ServiceDefaultKey, /// Data-key envelopes stored on object versions. ObjectEnvelopes, /// Session envelopes of multipart uploads that have not completed yet. InProgressMultipartUploads, } /// Why an entry appears in [`KeyImpactReport::references`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum KeyReferenceKind { /// A bucket's default encryption configuration names the key. BucketDefaultEncryption, /// The key is the KMS service's configured default key. ServiceDefaultKey, /// A whole source in [`ReferenceCoverage::scanned`] could not be /// enumerated. Reported as a reference, not as an absence: a source that /// cannot be read may hold references, and destroying material on the /// strength of an unanswered question is the one outcome that cannot be /// undone. UnreadableSource, /// One resource inside an otherwise readable source could not be /// inspected. Reported for the same reason as [`Self::UnreadableSource`]. UnreadableResource, } /// One thing that points at a key, or one place that could not be checked. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct KeyReference { /// Machine-readable category. pub kind: KeyReferenceKind, /// Identifier of the referencing resource: a bucket name for bucket /// configuration, the key id for the service default key, the affected /// source or resource for the unreadable kinds. Never key material. pub id: String, /// Human-readable detail. Identifiers only; never secrets or material. pub detail: String, } /// Which sources a report consulted, and which it did not look at at all. /// /// `not_scanned` is mandatory rather than implied: a caller reading an empty /// reference list has to be able to see, from the report alone, what was left /// out of it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReferenceCoverage { /// Sources this report enumerated. pub scanned: Vec, /// Sources this report does not cover at all. pub not_scanned: Vec, } /// How far a report's reference list can be trusted. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ReferenceCompleteness { /// Every reference within [`ReferenceCoverage::scanned`] was enumerated. /// Reserved for configuration-layer facts, which are finite, cheap to /// read, and therefore exhaustively decidable. Exact, /// The list holds what a snapshot happened to observe within the scanned /// scopes. Absence of a reference is not evidence that none exists. ObservedOnly, /// At least one scanned source could not be read, so the list is not a /// statement about the key at all. Unavailable, } /// What the server can currently say about who points at a key. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct KeyImpactReport { /// Key the report is about. pub key_id: String, /// How far [`Self::references`] can be trusted. pub completeness: ReferenceCompleteness, /// Which sources were consulted and which were not. pub coverage: ReferenceCoverage, /// References found, plus one entry per source that could not be read. pub references: Vec, } impl KeyImpactReport { /// An empty report over the configuration layer: bucket default encryption /// settings and the service default key. /// /// Both are finite and cheap to enumerate, so a report that reads them all /// stays [`ReferenceCompleteness::Exact`]; object-level scopes are /// declared as not scanned and stay that way. pub fn configuration_layer(key_id: impl Into) -> Self { Self { key_id: key_id.into(), completeness: ReferenceCompleteness::Exact, coverage: ReferenceCoverage { scanned: vec![ReferenceScope::BucketDefaultEncryption, ReferenceScope::ServiceDefaultKey], not_scanned: vec![ReferenceScope::ObjectEnvelopes, ReferenceScope::InProgressMultipartUploads], }, references: Vec::new(), } } /// Record one reference. /// /// An unreadable source or resource downgrades the report to /// [`ReferenceCompleteness::Unavailable`] here rather than at the call /// site, so no producer can report a partially read source as `Exact`. pub fn push_reference(&mut self, reference: KeyReference) { if matches!(reference.kind, KeyReferenceKind::UnreadableSource | KeyReferenceKind::UnreadableResource) { self.completeness = ReferenceCompleteness::Unavailable; } self.references.push(reference); } /// Whether this report on its own is reason to refuse destroying the key's /// material right now. /// /// The two answers are not symmetric. `true` is a decision: something /// points at the key, or a source that might could not be read. `false` /// only means this report contributes no objection — it is never a /// clearance, and must not be used to skip, shorten, or satisfy any other /// check on the deletion path. pub fn blocks_destruction(&self) -> bool { // The completeness test is redundant while every producer records an // unreadable source as a reference, and stays here so that a future // producer which forgets to cannot turn an unanswered question into a // silent clearance. !self.references.is_empty() || matches!(self.completeness, ReferenceCompleteness::Unavailable) } } #[cfg(test)] mod tests { use super::*; fn bucket_reference(bucket: &str) -> KeyReference { KeyReference { kind: KeyReferenceKind::BucketDefaultEncryption, id: bucket.to_string(), detail: format!("bucket {bucket} encrypts new objects with this key by default"), } } #[test] fn a_fresh_configuration_report_declares_the_object_layer_unscanned() { let report = KeyImpactReport::configuration_layer("kms-key-1"); assert_eq!(report.completeness, ReferenceCompleteness::Exact); assert!(report.references.is_empty()); assert_eq!( report.coverage.not_scanned, vec![ReferenceScope::ObjectEnvelopes, ReferenceScope::InProgressMultipartUploads], "an empty reference list is only readable next to what was left unscanned" ); assert!(!report.blocks_destruction()); } #[test] fn any_reference_blocks_destruction() { let mut report = KeyImpactReport::configuration_layer("kms-key-1"); report.push_reference(bucket_reference("sse-bucket")); assert!(report.blocks_destruction()); assert_eq!( report.completeness, ReferenceCompleteness::Exact, "a fully readable configuration layer stays exact even when it holds references" ); } #[test] fn an_unreadable_source_is_never_reported_as_an_absence() { for kind in [KeyReferenceKind::UnreadableSource, KeyReferenceKind::UnreadableResource] { let mut report = KeyImpactReport::configuration_layer("kms-key-1"); report.push_reference(KeyReference { kind, id: "bucket-default-encryption".to_string(), detail: "configuration could not be read".to_string(), }); assert_eq!( report.completeness, ReferenceCompleteness::Unavailable, "{kind:?} must downgrade completeness" ); assert!(report.blocks_destruction(), "{kind:?} must block destruction"); } } #[test] fn unavailable_completeness_blocks_even_without_references() { // Guards the fail-closed fallback for a producer that marks a report // unavailable without recording why. let report = KeyImpactReport { completeness: ReferenceCompleteness::Unavailable, ..KeyImpactReport::configuration_layer("kms-key-1") }; assert!(report.references.is_empty()); assert!(report.blocks_destruction()); } #[test] fn report_round_trips_through_json() { let mut report = KeyImpactReport::configuration_layer("kms-key-1"); report.push_reference(bucket_reference("sse-bucket")); report.push_reference(KeyReference { kind: KeyReferenceKind::ServiceDefaultKey, id: "kms-key-1".to_string(), detail: "configured as the KMS service default key".to_string(), }); let json = serde_json::to_string(&report).expect("serialization should succeed"); let decoded: KeyImpactReport = serde_json::from_str(&json).expect("deserialization should succeed"); assert_eq!(decoded, report); } /// The wire shape is the contract callers build UIs on: a boolean that /// reads as "this key is unused" must never appear in it, however the /// report is populated. #[test] fn the_wire_shape_asserts_nothing_about_absence_of_use() { let mut referenced = KeyImpactReport::configuration_layer("kms-key-1"); referenced.push_reference(bucket_reference("sse-bucket")); for report in [KeyImpactReport::configuration_layer("kms-key-1"), referenced] { let json = serde_json::to_value(&report).expect("serialization should succeed"); let mut fields: Vec<&str> = json .as_object() .expect("report is a JSON object") .keys() .map(String::as_str) .collect(); fields.sort_unstable(); assert_eq!(fields, vec!["completeness", "coverage", "key_id", "references"]); for forbidden in ["in_use", "unused", "unreferenced", "safe_to_delete", "deletable"] { assert!(!json.to_string().contains(forbidden), "report must not carry a `{forbidden}` claim"); } } } }