diff --git a/Cargo.lock b/Cargo.lock index 4df37c0ba..e1fdb2d33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9971,6 +9971,13 @@ dependencies = [ "uuid", ] +[[package]] +name = "rustfs-security-governance" +version = "1.0.0-beta.7" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "rustfs-signer" version = "1.0.0-beta.7" diff --git a/Cargo.toml b/Cargo.toml index 7fdc9b7f6..7af8d4b73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ members = [ "crates/s3select-api", # S3 Select API interface "crates/s3select-query", # S3 Select query engine "crates/scanner", # Scanner for data integrity checks and health monitoring + "crates/security-governance", # Security governance contracts "crates/signer", # client signer "crates/targets", # Target-specific configurations and utilities "crates/trusted-proxies", # Trusted proxies management @@ -108,6 +109,7 @@ rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.7" } rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.7" } rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.7" } rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.7" } +rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.7" } rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.7" } rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.7" } rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.7" } diff --git a/crates/security-governance/Cargo.toml b/crates/security-governance/Cargo.toml new file mode 100644 index 000000000..f71e1b032 --- /dev/null +++ b/crates/security-governance/Cargo.toml @@ -0,0 +1,34 @@ +# 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. + +[package] +name = "rustfs-security-governance" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +homepage.workspace = true +description = "Security governance contracts for RustFS." +keywords = ["security", "governance", "admin", "rustfs"] +categories = ["web-programming", "development-tools"] + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +thiserror = { workspace = true } diff --git a/crates/security-governance/src/admin_matrix.rs b/crates/security-governance/src/admin_matrix.rs new file mode 100644 index 000000000..029d72f1e --- /dev/null +++ b/crates/security-governance/src/admin_matrix.rs @@ -0,0 +1,257 @@ +// 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, PartialOrd, Ord, Hash)] +pub enum HttpMethod { + Delete, + Get, + Head, + Post, + Put, +} + +impl HttpMethod { + pub const fn as_str(self) -> &'static str { + match self { + Self::Delete => "DELETE", + Self::Get => "GET", + Self::Head => "HEAD", + Self::Post => "POST", + Self::Put => "PUT", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AdminActionRef(&'static str); + +impl AdminActionRef { + pub const fn new(action: &'static str) -> Self { + Self(action) + } + + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PublicRouteKind { + ConsoleAsset, + Health, + OidcBootstrap, + StsFormPost, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RouteRiskLevel { + Normal, + Sensitive, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AdminRouteAccess { + AdminAction(AdminActionRef), + Public(PublicRouteKind), +} + +impl AdminRouteAccess { + pub const fn admin_action(self) -> Option { + match self { + Self::AdminAction(action) => Some(action), + Self::Public(_) => None, + } + } + + pub const fn public_kind(self) -> Option { + match self { + Self::AdminAction(_) => None, + Self::Public(kind) => Some(kind), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AdminRouteSpec { + method: HttpMethod, + path: &'static str, + access: AdminRouteAccess, + risk_level: RouteRiskLevel, +} + +impl AdminRouteSpec { + pub const fn admin(method: HttpMethod, path: &'static str, action: AdminActionRef, risk_level: RouteRiskLevel) -> Self { + Self { + method, + path, + access: AdminRouteAccess::AdminAction(action), + risk_level, + } + } + + pub const fn public(method: HttpMethod, path: &'static str, kind: PublicRouteKind, risk_level: RouteRiskLevel) -> Self { + Self { + method, + path, + access: AdminRouteAccess::Public(kind), + risk_level, + } + } + + pub const fn method(self) -> HttpMethod { + self.method + } + + pub const fn path(self) -> &'static str { + self.path + } + + pub const fn access(self) -> AdminRouteAccess { + self.access + } + + pub const fn risk_level(self) -> RouteRiskLevel { + self.risk_level + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum AdminRouteMatrixError { + #[error("admin route spec at index {index} has an empty path")] + EmptyPath { index: usize }, + + #[error("admin route spec at index {index} for {method} {path} has an empty admin action")] + EmptyAdminAction { + index: usize, + method: &'static str, + path: &'static str, + }, + + #[error("duplicate admin route spec for {method} {path}")] + DuplicateRoute { method: &'static str, path: &'static str }, +} + +pub fn validate_admin_route_specs(specs: &[AdminRouteSpec]) -> Result<(), AdminRouteMatrixError> { + let mut routes = BTreeSet::new(); + + for (index, spec) in specs.iter().copied().enumerate() { + if spec.path.trim().is_empty() { + return Err(AdminRouteMatrixError::EmptyPath { index }); + } + + if let Some(action) = spec.access.admin_action() + && action.as_str().trim().is_empty() + { + return Err(AdminRouteMatrixError::EmptyAdminAction { + index, + method: spec.method.as_str(), + path: spec.path, + }); + } + + if !routes.insert((spec.method, spec.path)) { + return Err(AdminRouteMatrixError::DuplicateRoute { + method: spec.method.as_str(), + path: spec.path, + }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SERVER_INFO: AdminActionRef = AdminActionRef::new("ServerInfoAdminAction"); + + #[test] + fn validates_admin_and_public_route_specs() { + let specs = [ + AdminRouteSpec::admin(HttpMethod::Get, "/rustfs/admin/v3/info", SERVER_INFO, RouteRiskLevel::Sensitive), + AdminRouteSpec::public(HttpMethod::Post, "/", PublicRouteKind::StsFormPost, RouteRiskLevel::Sensitive), + ]; + + assert!(validate_admin_route_specs(&specs).is_ok()); + assert_eq!(specs[0].access().admin_action().expect("admin spec must expose action"), SERVER_INFO); + assert_eq!( + specs[1].access().public_kind().expect("public spec must expose public kind"), + PublicRouteKind::StsFormPost + ); + } + + #[test] + fn rejects_duplicate_method_and_path() { + let specs = [ + AdminRouteSpec::admin(HttpMethod::Get, "/rustfs/admin/v3/info", SERVER_INFO, RouteRiskLevel::Sensitive), + AdminRouteSpec::admin( + HttpMethod::Get, + "/rustfs/admin/v3/info", + AdminActionRef::new("StorageInfoAdminAction"), + RouteRiskLevel::Sensitive, + ), + ]; + + let err = validate_admin_route_specs(&specs).expect_err("duplicate route spec should fail validation"); + + assert_eq!( + err, + AdminRouteMatrixError::DuplicateRoute { + method: "GET", + path: "/rustfs/admin/v3/info" + } + ); + } + + #[test] + fn rejects_empty_paths() { + let specs = [AdminRouteSpec::admin( + HttpMethod::Get, + " ", + SERVER_INFO, + RouteRiskLevel::Sensitive, + )]; + + let err = validate_admin_route_specs(&specs).expect_err("empty path should fail validation"); + + assert_eq!(err, AdminRouteMatrixError::EmptyPath { index: 0 }); + } + + #[test] + fn rejects_empty_admin_actions() { + let specs = [AdminRouteSpec::admin( + HttpMethod::Get, + "/rustfs/admin/v3/info", + AdminActionRef::new(" "), + RouteRiskLevel::Sensitive, + )]; + + let err = validate_admin_route_specs(&specs).expect_err("empty admin action should fail validation"); + + assert_eq!( + err, + AdminRouteMatrixError::EmptyAdminAction { + index: 0, + method: "GET", + path: "/rustfs/admin/v3/info" + } + ); + } +} diff --git a/crates/security-governance/src/lib.rs b/crates/security-governance/src/lib.rs new file mode 100644 index 000000000..21fecd2d8 --- /dev/null +++ b/crates/security-governance/src/lib.rs @@ -0,0 +1,20 @@ +// 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. + +pub mod admin_matrix; + +pub use admin_matrix::{ + AdminActionRef, AdminRouteAccess, AdminRouteMatrixError, AdminRouteSpec, HttpMethod, PublicRouteKind, RouteRiskLevel, + validate_admin_route_specs, +}; diff --git a/docs/architecture/migration-progress.md b/docs/architecture/migration-progress.md index 67ee56357..a948fb6d5 100644 --- a/docs/architecture/migration-progress.md +++ b/docs/architecture/migration-progress.md @@ -5,15 +5,14 @@ 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-admin-route-matrix-guard` -- Baseline: `upstream/main` at `dee550a831cbfc24aa5cecd60f6f3a4cfe1a2e30` -- PR type for this branch: `test-only` +- Branch: `overtrue/arch-security-governance-types` +- Baseline: `upstream/main` at `c03c0ebc363209a4820e6d6f2267e0342c6a7782` +- PR type for this branch: `contract` - Runtime behavior changes: none -- Rust code changes: add test-only admin route matrix coverage, test-only - registered route tracking, and a private shared admin route registration helper - so tests exercise the production registration sequence. +- Rust code changes: add the pure `rustfs-security-governance` contract crate + with admin route matrix metadata types, typed validation errors, and unit tests. - CI/script changes: none -- Docs changes: record the route matrix guard handoff. +- Docs changes: record the Phase 1 security-governance contract handoff. ## Phase 0 Tasks @@ -63,43 +62,61 @@ Status values: `[ ]` not started, `[~]` in progress, `[x]` complete, `[!]` block when a register entry lacks a source marker, or when a source marker omits a removal condition. +## Phase 1 Security Governance Tasks + +- [x] `S-001` Add `crates/security-governance`. + - Acceptance: the crate is a workspace member and has no dependency on + `rustfs`, `ecstore`, admin handlers, Axum, or runtime state. + - Verification: `cargo check -p rustfs-security-governance`. +- [x] `S-002` Add admin route matrix core types. + - Acceptance: `AdminRouteSpec`, `AdminRouteAccess`, `AdminActionRef`, + `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. +- [ ] `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`: define the config-model contract surface while preserving the - existing `Config`, `KV`, and `KVS` behavior. -2. `ci-gate`: add focused checks for public re-export and storage trait coverage - before pure moves. +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. ## Pre-Push Review Log | Expert | Status | Notes | |---|---|---| -| Quality/architecture | pass | Confirmed the matrix helpers keep the snapshot explicit, `S3Router` instrumentation is fully `cfg(test)`, the production registration order is shared through one private helper, and the non-test insertion path keeps existing route construction/insert behavior | -| Migration preservation | pass | Confirmed this remains `test-only` in effect: no route handler/auth/alias semantics changed, no storage hot-path/global-state/crate-split changes, and MinIO admin alias coverage is expanded across all admin-prefix matrix entries | -| Testing/verification | pass | Confirmed focused route tests pin `ENV_HEALTH_ENDPOINT_ENABLE=true`, use the production registration helper, and pass with formatting, non-test cargo check, migration guard scripts, diff check, full `make pre-commit`, and temporary unaccounted-route negative coverage | +| 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 | ## Verification Notes Passed: +- `cargo check -p rustfs-security-governance` +- `cargo test -p rustfs-security-governance` - `cargo fmt --all --check` -- `cargo check -p rustfs --lib` -- `cargo test -p rustfs admin::route_registration_test -- --nocapture` +- `cargo tree -p rustfs-security-governance --edges normal` - `./scripts/check_architecture_migration_rules.sh` - `./scripts/check_layer_dependencies.sh` - `./scripts/check_metrics_migration_refs.sh` - `git diff --check` -- temporary negative check for an unaccounted admin route registration - `make pre-commit` ## Handoff Notes -- Keep Phase 0 PRs small. Do not move Config, Storage API, Runtime, or ECStore - code inside this `test-only` branch. -- Route matrix instrumentation must remain test-only and must not alter dispatch - or auth behavior. +- Keep this Phase 1 branch as a pure `contract` PR. Do not add + `rustfs/src/admin` integration, route registration changes, auth enforcement, + Config moves, Storage API moves, Runtime moves, or ECStore moves. +- The new crate is allowed to depend on generic Rust libraries such as + `thiserror`, but must stay independent from implementation crates and runtime + state. - Do not add temporary compatibility code without a matching `RUSTFS_COMPAT_TODO()` marker and cleanup-register entry. -- The next config-model PR must preserve the current tuple-struct shapes and - persistence behavior before introducing narrower provider contracts. -- Do not move `config::com` until a dedicated helper-user inventory covers - ECStore-internal persistence paths as well as adjacent non-model users. +- The next admin route policy PR may consume the contract types, but must + preserve current route registration, alias canonicalization, public + exceptions, and handler-level authorization behavior.