diff --git a/crates/security-governance/src/lib.rs b/crates/security-governance/src/lib.rs index 21fecd2d8..edc354293 100644 --- a/crates/security-governance/src/lib.rs +++ b/crates/security-governance/src/lib.rs @@ -13,8 +13,16 @@ // limitations under the License. pub mod admin_matrix; +pub mod redaction; +pub mod serde_policy; +pub mod supply_chain; pub use admin_matrix::{ AdminActionRef, AdminRouteAccess, AdminRouteMatrixError, AdminRouteSpec, HttpMethod, PublicRouteKind, RouteRiskLevel, validate_admin_route_specs, }; +pub use redaction::{RedactionLevel, RedactionPolicyError, RedactionRule, validate_redaction_rules}; +pub use serde_policy::{SerdePolicy, SerdePolicyError, SerdePolicyKind, UnknownFieldPolicy, validate_serde_policies}; +pub use supply_chain::{ + ArtifactIntegrityPolicy, ArtifactSourceKind, SupplyChainPolicyError, validate_artifact_integrity_policies, +}; diff --git a/crates/security-governance/src/redaction.rs b/crates/security-governance/src/redaction.rs new file mode 100644 index 000000000..ff5a97552 --- /dev/null +++ b/crates/security-governance/src/redaction.rs @@ -0,0 +1,150 @@ +// 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. + +use std::collections::BTreeSet; + +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RedactionLevel { + Public, + Sensitive, + Secret, +} + +impl RedactionLevel { + pub const fn requires_redaction(self) -> bool { + matches!(self, Self::Sensitive | Self::Secret) + } + + pub const fn is_secret(self) -> bool { + matches!(self, Self::Secret) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RedactionRule { + field: &'static str, + level: RedactionLevel, + reason: &'static str, +} + +impl RedactionRule { + pub const fn new(field: &'static str, level: RedactionLevel, reason: &'static str) -> Self { + Self { field, level, reason } + } + + pub const fn field(self) -> &'static str { + self.field + } + + pub const fn level(self) -> RedactionLevel { + self.level + } + + pub const fn reason(self) -> &'static str { + self.reason + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RedactionPolicyError { + #[error("redaction rule at index {index} has an empty field")] + EmptyField { index: usize }, + + #[error("redaction rule at index {index} for {field} has an empty reason")] + EmptyReason { index: usize, field: &'static str }, + + #[error("duplicate redaction rule for {field}")] + DuplicateField { field: &'static str }, +} + +pub fn validate_redaction_rules(rules: &[RedactionRule]) -> Result<(), RedactionPolicyError> { + let mut fields = BTreeSet::new(); + + for (index, rule) in rules.iter().copied().enumerate() { + if rule.field.trim().is_empty() { + return Err(RedactionPolicyError::EmptyField { index }); + } + + if rule.reason.trim().is_empty() { + return Err(RedactionPolicyError::EmptyReason { + index, + field: rule.field, + }); + } + + if !fields.insert(rule.field) { + return Err(RedactionPolicyError::DuplicateField { field: rule.field }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_redaction_rules() { + let rules = [ + RedactionRule::new("access_key", RedactionLevel::Secret, "credential material"), + RedactionRule::new("region", RedactionLevel::Public, "non-sensitive routing metadata"), + ]; + + assert!(validate_redaction_rules(&rules).is_ok()); + assert!(rules[0].level().requires_redaction()); + assert!(rules[0].level().is_secret()); + assert!(!rules[1].level().requires_redaction()); + assert_eq!(rules[0].field(), "access_key"); + assert_eq!(rules[0].reason(), "credential material"); + } + + #[test] + fn rejects_empty_fields() { + let rules = [RedactionRule::new(" ", RedactionLevel::Secret, "credential material")]; + + let err = validate_redaction_rules(&rules).expect_err("empty field should fail validation"); + + assert_eq!(err, RedactionPolicyError::EmptyField { index: 0 }); + } + + #[test] + fn rejects_empty_reasons() { + let rules = [RedactionRule::new("access_key", RedactionLevel::Secret, " ")]; + + let err = validate_redaction_rules(&rules).expect_err("empty reason should fail validation"); + + assert_eq!( + err, + RedactionPolicyError::EmptyReason { + index: 0, + field: "access_key" + } + ); + } + + #[test] + fn rejects_duplicate_fields() { + let rules = [ + RedactionRule::new("access_key", RedactionLevel::Secret, "credential material"), + RedactionRule::new("access_key", RedactionLevel::Sensitive, "audit-only secret reference"), + ]; + + let err = validate_redaction_rules(&rules).expect_err("duplicate field should fail validation"); + + assert_eq!(err, RedactionPolicyError::DuplicateField { field: "access_key" }); + } +} diff --git a/crates/security-governance/src/serde_policy.rs b/crates/security-governance/src/serde_policy.rs new file mode 100644 index 000000000..53d7eae8e --- /dev/null +++ b/crates/security-governance/src/serde_policy.rs @@ -0,0 +1,172 @@ +// 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. + +use std::collections::BTreeSet; + +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UnknownFieldPolicy { + Deny, + Warn, + Preserve, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SerdePolicyKind { + StrictIngress, + TolerantCompat, + PersistentLegacy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SerdePolicy { + target: &'static str, + kind: SerdePolicyKind, + unknown_fields: UnknownFieldPolicy, +} + +impl SerdePolicy { + pub const fn new(target: &'static str, kind: SerdePolicyKind, unknown_fields: UnknownFieldPolicy) -> Self { + Self { + target, + kind, + unknown_fields, + } + } + + pub const fn target(self) -> &'static str { + self.target + } + + pub const fn kind(self) -> SerdePolicyKind { + self.kind + } + + pub const fn unknown_fields(self) -> UnknownFieldPolicy { + self.unknown_fields + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SerdePolicyError { + #[error("serde policy at index {index} has an empty target")] + EmptyTarget { index: usize }, + + #[error("strict ingress serde policy for {target} must deny unknown fields")] + StrictIngressMustDeny { target: &'static str }, + + #[error("compat serde policy for {target} must not deny unknown fields")] + CompatMustNotDeny { target: &'static str }, + + #[error("duplicate serde policy for {target}")] + DuplicateTarget { target: &'static str }, +} + +pub fn validate_serde_policies(policies: &[SerdePolicy]) -> Result<(), SerdePolicyError> { + let mut targets = BTreeSet::new(); + + for (index, policy) in policies.iter().copied().enumerate() { + if policy.target.trim().is_empty() { + return Err(SerdePolicyError::EmptyTarget { index }); + } + + match (policy.kind, policy.unknown_fields) { + (SerdePolicyKind::StrictIngress, UnknownFieldPolicy::Deny) => {} + (SerdePolicyKind::StrictIngress, _) => { + return Err(SerdePolicyError::StrictIngressMustDeny { target: policy.target }); + } + (SerdePolicyKind::TolerantCompat | SerdePolicyKind::PersistentLegacy, UnknownFieldPolicy::Deny) => { + return Err(SerdePolicyError::CompatMustNotDeny { target: policy.target }); + } + (SerdePolicyKind::TolerantCompat | SerdePolicyKind::PersistentLegacy, _) => {} + } + + if !targets.insert(policy.target) { + return Err(SerdePolicyError::DuplicateTarget { target: policy.target }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_strict_and_compat_policies() { + let policies = [ + SerdePolicy::new("BucketPolicy", SerdePolicyKind::StrictIngress, UnknownFieldPolicy::Deny), + SerdePolicy::new("LegacyConfig", SerdePolicyKind::PersistentLegacy, UnknownFieldPolicy::Preserve), + SerdePolicy::new("ReplicationRule", SerdePolicyKind::TolerantCompat, UnknownFieldPolicy::Warn), + ]; + + assert!(validate_serde_policies(&policies).is_ok()); + assert_eq!(policies[0].target(), "BucketPolicy"); + assert_eq!(policies[0].kind(), SerdePolicyKind::StrictIngress); + assert_eq!(policies[0].unknown_fields(), UnknownFieldPolicy::Deny); + } + + #[test] + fn rejects_empty_targets() { + let policies = [SerdePolicy::new( + " ", + SerdePolicyKind::StrictIngress, + UnknownFieldPolicy::Deny, + )]; + + let err = validate_serde_policies(&policies).expect_err("empty target should fail validation"); + + assert_eq!(err, SerdePolicyError::EmptyTarget { index: 0 }); + } + + #[test] + fn rejects_strict_ingress_without_deny() { + let policies = [SerdePolicy::new( + "BucketPolicy", + SerdePolicyKind::StrictIngress, + UnknownFieldPolicy::Warn, + )]; + + let err = validate_serde_policies(&policies).expect_err("strict ingress should require deny"); + + assert_eq!(err, SerdePolicyError::StrictIngressMustDeny { target: "BucketPolicy" }); + } + + #[test] + fn rejects_compat_policy_with_deny() { + let policies = [SerdePolicy::new( + "LegacyConfig", + SerdePolicyKind::PersistentLegacy, + UnknownFieldPolicy::Deny, + )]; + + let err = validate_serde_policies(&policies).expect_err("compat policy should not deny unknown fields"); + + assert_eq!(err, SerdePolicyError::CompatMustNotDeny { target: "LegacyConfig" }); + } + + #[test] + fn rejects_duplicate_targets() { + let policies = [ + SerdePolicy::new("BucketPolicy", SerdePolicyKind::StrictIngress, UnknownFieldPolicy::Deny), + SerdePolicy::new("BucketPolicy", SerdePolicyKind::TolerantCompat, UnknownFieldPolicy::Warn), + ]; + + let err = validate_serde_policies(&policies).expect_err("duplicate target should fail validation"); + + assert_eq!(err, SerdePolicyError::DuplicateTarget { target: "BucketPolicy" }); + } +} diff --git a/crates/security-governance/src/supply_chain.rs b/crates/security-governance/src/supply_chain.rs new file mode 100644 index 000000000..9fc6c44be --- /dev/null +++ b/crates/security-governance/src/supply_chain.rs @@ -0,0 +1,277 @@ +// 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. + +use std::collections::BTreeSet; + +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ArtifactSourceKind { + WorkspaceBuild, + ThirdPartyDownload, + GeneratedReleaseAsset, +} + +impl ArtifactSourceKind { + pub const fn requires_digest(self) -> bool { + matches!(self, Self::ThirdPartyDownload | Self::GeneratedReleaseAsset) + } + + pub const fn requires_provenance(self) -> bool { + matches!(self, Self::GeneratedReleaseAsset) + } + + pub const fn requires_signature(self) -> bool { + matches!(self, Self::GeneratedReleaseAsset) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ArtifactIntegrityPolicy { + artifact: &'static str, + source: ArtifactSourceKind, + digest_required: bool, + signature_required: bool, + provenance_required: bool, +} + +impl ArtifactIntegrityPolicy { + pub const fn new( + artifact: &'static str, + source: ArtifactSourceKind, + digest_required: bool, + signature_required: bool, + provenance_required: bool, + ) -> Self { + Self { + artifact, + source, + digest_required, + signature_required, + provenance_required, + } + } + + pub const fn artifact(self) -> &'static str { + self.artifact + } + + pub const fn source(self) -> ArtifactSourceKind { + self.source + } + + pub const fn digest_required(self) -> bool { + self.digest_required + } + + pub const fn signature_required(self) -> bool { + self.signature_required + } + + pub const fn provenance_required(self) -> bool { + self.provenance_required + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SupplyChainPolicyError { + #[error("artifact integrity policy at index {index} has an empty artifact")] + EmptyArtifact { index: usize }, + + #[error("artifact integrity policy for {artifact} must require a digest")] + DigestRequired { artifact: &'static str }, + + #[error("artifact integrity policy for {artifact} must require provenance")] + ProvenanceRequired { artifact: &'static str }, + + #[error("artifact integrity policy for {artifact} must require a signature")] + SignatureRequired { artifact: &'static str }, + + #[error("duplicate artifact integrity policy for {artifact}")] + DuplicateArtifact { artifact: &'static str }, +} + +pub fn validate_artifact_integrity_policies(policies: &[ArtifactIntegrityPolicy]) -> Result<(), SupplyChainPolicyError> { + let mut artifacts = BTreeSet::new(); + + for (index, policy) in policies.iter().copied().enumerate() { + if policy.artifact.trim().is_empty() { + return Err(SupplyChainPolicyError::EmptyArtifact { index }); + } + + if policy.source.requires_digest() && !policy.digest_required { + return Err(SupplyChainPolicyError::DigestRequired { + artifact: policy.artifact, + }); + } + + if policy.source.requires_provenance() && !policy.provenance_required { + return Err(SupplyChainPolicyError::ProvenanceRequired { + artifact: policy.artifact, + }); + } + + if policy.source.requires_signature() && !policy.signature_required { + return Err(SupplyChainPolicyError::SignatureRequired { + artifact: policy.artifact, + }); + } + + if !artifacts.insert(policy.artifact) { + return Err(SupplyChainPolicyError::DuplicateArtifact { + artifact: policy.artifact, + }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_artifact_integrity_policies() { + let policies = [ + ArtifactIntegrityPolicy::new("rustfs-server", ArtifactSourceKind::WorkspaceBuild, false, true, false), + ArtifactIntegrityPolicy::new( + "rustfs-cli-windows-amd64.zip", + ArtifactSourceKind::GeneratedReleaseAsset, + true, + true, + true, + ), + ]; + + assert!(validate_artifact_integrity_policies(&policies).is_ok()); + assert_eq!(policies[0].artifact(), "rustfs-server"); + assert_eq!(policies[0].source(), ArtifactSourceKind::WorkspaceBuild); + assert!(policies[1].digest_required()); + assert!(policies[1].signature_required()); + assert!(policies[1].provenance_required()); + } + + #[test] + fn rejects_empty_artifacts() { + let policies = [ArtifactIntegrityPolicy::new( + " ", + ArtifactSourceKind::WorkspaceBuild, + false, + false, + false, + )]; + + let err = validate_artifact_integrity_policies(&policies).expect_err("empty artifact should fail validation"); + + assert_eq!(err, SupplyChainPolicyError::EmptyArtifact { index: 0 }); + } + + #[test] + fn rejects_external_artifacts_without_digest() { + let policies = [ArtifactIntegrityPolicy::new( + "third-party-tool", + ArtifactSourceKind::ThirdPartyDownload, + false, + false, + false, + )]; + + let err = validate_artifact_integrity_policies(&policies).expect_err("third-party artifact should require digest"); + + assert_eq!( + err, + SupplyChainPolicyError::DigestRequired { + artifact: "third-party-tool" + } + ); + } + + #[test] + fn rejects_generated_artifacts_without_digest() { + let policies = [ArtifactIntegrityPolicy::new( + "rustfs-cli-windows-amd64.zip", + ArtifactSourceKind::GeneratedReleaseAsset, + false, + true, + true, + )]; + + let err = validate_artifact_integrity_policies(&policies).expect_err("generated artifact should require digest"); + + assert_eq!( + err, + SupplyChainPolicyError::DigestRequired { + artifact: "rustfs-cli-windows-amd64.zip" + } + ); + } + + #[test] + fn rejects_generated_artifacts_without_provenance() { + let policies = [ArtifactIntegrityPolicy::new( + "rustfs-cli-windows-amd64.zip", + ArtifactSourceKind::GeneratedReleaseAsset, + true, + true, + false, + )]; + + let err = validate_artifact_integrity_policies(&policies).expect_err("generated artifact should require provenance"); + + assert_eq!( + err, + SupplyChainPolicyError::ProvenanceRequired { + artifact: "rustfs-cli-windows-amd64.zip" + } + ); + } + + #[test] + fn rejects_generated_artifacts_without_signature() { + let policies = [ArtifactIntegrityPolicy::new( + "rustfs-cli-windows-amd64.zip", + ArtifactSourceKind::GeneratedReleaseAsset, + true, + false, + true, + )]; + + let err = validate_artifact_integrity_policies(&policies).expect_err("generated artifact should require signature"); + + assert_eq!( + err, + SupplyChainPolicyError::SignatureRequired { + artifact: "rustfs-cli-windows-amd64.zip" + } + ); + } + + #[test] + fn rejects_duplicate_artifacts() { + let policies = [ + ArtifactIntegrityPolicy::new("rustfs-server", ArtifactSourceKind::WorkspaceBuild, false, true, false), + ArtifactIntegrityPolicy::new("rustfs-server", ArtifactSourceKind::WorkspaceBuild, false, false, false), + ]; + + let err = validate_artifact_integrity_policies(&policies).expect_err("duplicate artifact should fail validation"); + + assert_eq!( + err, + SupplyChainPolicyError::DuplicateArtifact { + artifact: "rustfs-server" + } + ); + } +} diff --git a/docs/architecture/migration-progress.md b/docs/architecture/migration-progress.md index a948fb6d5..ba469e625 100644 --- a/docs/architecture/migration-progress.md +++ b/docs/architecture/migration-progress.md @@ -5,14 +5,15 @@ Status values: `[ ]` not started, `[~]` in progress, `[x]` complete, `[!]` block ## Current Context - Issue: [`rustfs/backlog#660`](https://github.com/rustfs/backlog/issues/660) -- Branch: `overtrue/arch-security-governance-types` -- Baseline: `upstream/main` at `c03c0ebc363209a4820e6d6f2267e0342c6a7782` +- Branch: `overtrue/arch-security-governance-policy-types` +- Baseline: `upstream/main` at `7a9bf707ee66e779f85e6e00cedfaa10ec2af4c2` - PR type for this branch: `contract` - Runtime behavior changes: none -- Rust code changes: add the pure `rustfs-security-governance` contract crate - with admin route matrix metadata types, typed validation errors, and unit tests. +- Rust code changes: add redaction, serde policy, and artifact integrity + contract modules to the pure rustfs-security-governance crate, with typed + validation errors and unit tests. - CI/script changes: none -- Docs changes: record the Phase 1 security-governance contract handoff. +- Docs changes: record the Phase 1 policy contract handoff. ## Phase 0 Tasks @@ -73,26 +74,37 @@ Status values: `[ ]` not started, `[~]` in progress, `[x]` complete, `[!]` block `PublicRouteKind`, `RouteRiskLevel`, and validation errors model route governance metadata without registering routes or enforcing auth. - Verification: `cargo test -p rustfs-security-governance`. -- [ ] `S-003` Add redaction contract types. -- [ ] `S-004` Add serde policy marker types. -- [ ] `S-005` Add supply-chain policy contract types. +- [x] `S-003` Add redaction contract types. + - Acceptance: `RedactionRule`, `RedactionLevel`, and validation errors model + sensitive field handling without logging, masking, or runtime integration. + - Verification: `cargo test -p rustfs-security-governance`. +- [x] `S-004` Add serde policy marker types. + - Acceptance: `SerdePolicy`, `SerdePolicyKind`, `UnknownFieldPolicy`, and + validation errors model strict ingress and compatibility serde contracts + without changing deserialization behavior. + - Verification: `cargo test -p rustfs-security-governance`. +- [x] `S-005` Add supply-chain policy contract types. + - Acceptance: `ArtifactIntegrityPolicy`, `ArtifactSourceKind`, and validation + errors model digest, signature, and provenance requirements without changing + release or CI behavior. + - Verification: `cargo test -p rustfs-security-governance`. - [ ] `S-006` Add `rustfs/src/admin/route_policy.rs` backed by these contract types, without changing route registration or auth behavior. ## Next PRs -1. `contract`: add redaction, serde policy, or supply-chain governance contract - modules inside rustfs-security-governance. -2. `contract`: add an admin route policy table that consumes the new - admin_matrix types while preserving route registration and auth behavior. +1. `contract`: add an admin route policy table that consumes the new admin + route matrix types while preserving route registration and auth behavior. +2. `contract`: add initial policy inventory tables for redaction, serde, or + supply-chain governance only after the contract shape remains stable. ## Pre-Push Review Log | Expert | Status | Notes | |---|---|---| -| Quality/architecture | pass | Confirmed this is a pure `contract` PR: the new crate has a narrow admin_matrix module, typed errors via thiserror, no dependency on implementation crates, and no route registration or auth integration | -| Migration preservation | pass | Confirmed no runtime code paths changed: no admin handler/router edits, no startup/global-state/storage hot-path movement, no compatibility wrappers, and no behavior drift | -| Testing/verification | pass | Confirmed focused unit tests cover valid admin/public specs, duplicate method/path rejection, empty path rejection, and empty action rejection; focused checks, migration guard scripts, dependency tree review, diff check, and full `make pre-commit` passed | +| Quality/architecture | pass | Confirmed the staged diff includes the new modules, keeps a pure `contract` design, uses clear API names and typed errors, and does not introduce runtime/admin/router/auth/startup/storage/config/global-state integration | +| Migration preservation | pass | Confirmed this PR only completes `S-003` through `S-005`, does not shift away from backlog #660, does not touch storage hot paths or global-state migration, and does not need compatibility markers | +| Testing/verification | pass | Confirmed focused contract tests assert valid and invalid policy behavior, production logic was not changed to satisfy tests, focused checks passed, and full `make pre-commit` passed | ## Verification Notes @@ -107,6 +119,11 @@ Passed: - `git diff --check` - `make pre-commit` +Notes: +- `cargo test -p rustfs-security-governance` passed 20 unit tests. +- `make pre-commit` passed, including 5513 nextest tests, 105 skipped tests, + and workspace doctests. + ## Handoff Notes - Keep this Phase 1 branch as a pure `contract` PR. Do not add