diff --git a/fabricks-e2e/Cargo.toml b/fabricks-e2e/Cargo.toml index 9d39125..afce25e 100644 --- a/fabricks-e2e/Cargo.toml +++ b/fabricks-e2e/Cargo.toml @@ -43,3 +43,7 @@ path = "tests/volume_service.rs" [[test]] name = "autoscaler" path = "tests/autoscaler.rs" + +[[test]] +name = "policy" +path = "tests/policy.rs" diff --git a/fabricks-e2e/tests/policy.rs b/fabricks-e2e/tests/policy.rs new file mode 100644 index 0000000..e39a988 --- /dev/null +++ b/fabricks-e2e/tests/policy.rs @@ -0,0 +1,434 @@ +//! End-to-end tests for policy engine functionality. +//! +//! These tests verify that policy evaluation works correctly +//! with deny, require, and warn rules. + +use std::collections::HashMap; + +use fabricks_common::models::mortar::{DenyRule, Policy, WarnRule}; +use fabricksd::events::EventType; +use fabricksd::policy::{PolicyDecision, PolicyInfo}; + +/// Test that PolicyManager can load and unload policies. +#[tokio::test] +async fn test_policy_load_unload() { + use fabricksd::events::EventBus; + use fabricksd::policy::PolicyManager; + use std::sync::Arc; + + let event_bus = Arc::new(EventBus::new(100, 100)); + let manager = PolicyManager::new(event_bus); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + mappings.insert("db".to_string(), "svc-db-1".to_string()); + + let policy = Policy { + description: Some("Test policy".to_string()), + deny: None, + require: None, + warn: None, + }; + + // Load policy + manager + .load_policies("mortar-test", policy, mappings) + .await; + + // Verify loaded + let policies = manager.list_policies().await; + assert_eq!(policies.len(), 1); + assert_eq!(policies[0].mortar_id, "mortar-test"); + + // Unload policy + manager.unload_policies("mortar-test").await; + + // Verify unloaded + let policies = manager.list_policies().await; + assert!(policies.is_empty()); +} + +/// Test deny rule blocks connections. +#[tokio::test] +async fn test_deny_rule_blocks_connection() { + use fabricksd::events::EventBus; + use fabricksd::policy::PolicyManager; + use std::sync::Arc; + + let event_bus = Arc::new(EventBus::new(100, 100)); + let manager = PolicyManager::new(event_bus); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + mappings.insert("db".to_string(), "svc-db-1".to_string()); + + let policy = Policy { + description: Some("Block api from db".to_string()), + deny: Some(vec![DenyRule { + from: Some(vec!["api".to_string()]), + to: Some(vec!["db".to_string()]), + except: None, + reason: Some("API cannot directly access database".to_string()), + }]), + require: None, + warn: None, + }; + + manager + .load_policies("mortar-test", policy, mappings) + .await; + + // api -> db should be denied + let decision = manager + .evaluate_connection("svc-api-1", "svc-db-1") + .await; + + assert!(decision.is_denied()); + assert!(decision.reason().unwrap().contains("API cannot directly access database")); +} + +/// Test deny rule with exception allows excepted connections. +#[tokio::test] +async fn test_deny_rule_with_exception() { + use fabricksd::events::EventBus; + use fabricksd::policy::PolicyManager; + use std::sync::Arc; + + let event_bus = Arc::new(EventBus::new(100, 100)); + let manager = PolicyManager::new(event_bus); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + mappings.insert("admin".to_string(), "svc-admin-1".to_string()); + mappings.insert("db".to_string(), "svc-db-1".to_string()); + + let policy = Policy { + description: Some("Block all to db except admin".to_string()), + deny: Some(vec![DenyRule { + from: None, // All sources + to: Some(vec!["db".to_string()]), + except: Some(vec!["admin".to_string()]), + reason: Some("Only admin can access database".to_string()), + }]), + require: None, + warn: None, + }; + + manager + .load_policies("mortar-test", policy, mappings) + .await; + + // api -> db should be denied + let decision = manager + .evaluate_connection("svc-api-1", "svc-db-1") + .await; + assert!(decision.is_denied()); + + // admin -> db should be allowed (exception) + let decision = manager + .evaluate_connection("svc-admin-1", "svc-db-1") + .await; + assert!(decision.is_allowed()); +} + +/// Test warn rule allows but logs warning. +#[tokio::test] +async fn test_warn_rule_allows_with_warning() { + use fabricksd::events::EventBus; + use fabricksd::policy::PolicyManager; + use std::sync::Arc; + + let event_bus = Arc::new(EventBus::new(100, 100)); + let manager = PolicyManager::new(event_bus); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + + let policy = Policy { + description: Some("Warn on cross-network".to_string()), + deny: None, + require: None, + warn: Some(vec![WarnRule { + cross_network: Some(true), + except: None, + }]), + }; + + manager + .load_policies("mortar-test", policy, mappings) + .await; + + // Connection to external should warn + let decision = manager + .evaluate_connection("svc-api-1", "https://api.example.com") + .await; + + assert!(decision.is_allowed()); + assert!(matches!(decision, PolicyDecision::Warn { .. })); +} + +/// Test connections without policies are allowed. +#[tokio::test] +async fn test_no_policy_allows_connection() { + use fabricksd::events::EventBus; + use fabricksd::policy::PolicyManager; + use std::sync::Arc; + + let event_bus = Arc::new(EventBus::new(100, 100)); + let manager = PolicyManager::new(event_bus); + + // No policies loaded + let decision = manager + .evaluate_connection("svc-unknown", "svc-other") + .await; + + assert!(decision.is_allowed()); + assert!(matches!(decision, PolicyDecision::Allow)); +} + +/// Test multiple deny rules - first match wins. +#[tokio::test] +async fn test_multiple_deny_rules() { + use fabricksd::events::EventBus; + use fabricksd::policy::PolicyManager; + use std::sync::Arc; + + let event_bus = Arc::new(EventBus::new(100, 100)); + let manager = PolicyManager::new(event_bus); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + mappings.insert("worker".to_string(), "svc-worker-1".to_string()); + mappings.insert("db".to_string(), "svc-db-1".to_string()); + + let policy = Policy { + description: Some("Multiple deny rules".to_string()), + deny: Some(vec![ + DenyRule { + from: Some(vec!["api".to_string()]), + to: Some(vec!["db".to_string()]), + except: None, + reason: Some("Reason 1".to_string()), + }, + DenyRule { + from: Some(vec!["worker".to_string()]), + to: Some(vec!["db".to_string()]), + except: None, + reason: Some("Reason 2".to_string()), + }, + ]), + require: None, + warn: None, + }; + + manager + .load_policies("mortar-test", policy, mappings) + .await; + + // api -> db matches first rule + let decision = manager + .evaluate_connection("svc-api-1", "svc-db-1") + .await; + assert!(decision.is_denied()); + assert!(decision.reason().unwrap().contains("Reason 1")); + + // worker -> db matches second rule + let decision = manager + .evaluate_connection("svc-worker-1", "svc-db-1") + .await; + assert!(decision.is_denied()); + assert!(decision.reason().unwrap().contains("Reason 2")); +} + +/// Test PolicyInfo serialization. +#[test] +fn test_policy_info_serialization() { + let info = PolicyInfo { + mortar_id: "mortar-1".to_string(), + description: Some("Test".to_string()), + deny_rules: 2, + require_rules: 1, + warn_rules: 0, + services: vec!["api".to_string(), "db".to_string()], + }; + + let json = serde_json::to_string(&info).expect("should serialize"); + assert!(json.contains("\"mortar_id\":\"mortar-1\"")); + assert!(json.contains("\"deny_rules\":2")); +} + +/// Test PolicyDecision serialization. +#[test] +fn test_policy_decision_serialization() { + let allow = PolicyDecision::Allow; + let json = serde_json::to_string(&allow).expect("should serialize"); + assert!(json.contains("\"decision\":\"allow\"")); + + let deny = PolicyDecision::deny("rule 1", "not allowed"); + let json = serde_json::to_string(&deny).expect("should serialize"); + assert!(json.contains("\"decision\":\"deny\"")); + assert!(json.contains("\"reason\":\"not allowed\"")); + + let warn = PolicyDecision::warn("rule 2", "suspicious"); + let json = serde_json::to_string(&warn).expect("should serialize"); + assert!(json.contains("\"decision\":\"warn\"")); +} + +/// Test policy events are emitted. +#[tokio::test] +async fn test_policy_events_emitted() { + use fabricksd::events::EventBus; + use fabricksd::policy::PolicyManager; + use std::sync::Arc; + + let event_bus = Arc::new(EventBus::new(100, 100)); + let manager = PolicyManager::new(Arc::clone(&event_bus)); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + mappings.insert("db".to_string(), "svc-db-1".to_string()); + + let policy = Policy { + description: Some("Test policy".to_string()), + deny: Some(vec![DenyRule { + from: Some(vec!["api".to_string()]), + to: Some(vec!["db".to_string()]), + except: None, + reason: Some("Denied".to_string()), + }]), + require: None, + warn: None, + }; + + manager + .load_policies("mortar-test", policy, mappings) + .await; + + // Subscribe to events before triggering the connection + let mut rx = event_bus.subscribe().await; + + // Trigger a denied connection + let _ = manager + .evaluate_connection("svc-api-1", "svc-db-1") + .await; + + // Check events were emitted + let mut found_evaluated = false; + let mut found_violation = false; + + // Wait for events with timeout + for _ in 0..10 { + match tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await { + Ok(Some(event)) => { + if event.event_type == EventType::PolicyEvaluated { + found_evaluated = true; + } + if event.event_type == EventType::PolicyViolation { + found_violation = true; + } + if found_evaluated && found_violation { + break; + } + } + _ => break, + } + } + + assert!(found_evaluated, "Should have PolicyEvaluated event"); + assert!(found_violation, "Should have PolicyViolation event"); +} + +/// Test wildcard deny rule. +#[tokio::test] +async fn test_wildcard_deny_rule() { + use fabricksd::events::EventBus; + use fabricksd::policy::PolicyManager; + use std::sync::Arc; + + let event_bus = Arc::new(EventBus::new(100, 100)); + let manager = PolicyManager::new(event_bus); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + mappings.insert("db".to_string(), "svc-db-1".to_string()); + + let policy = Policy { + description: Some("Deny all".to_string()), + deny: Some(vec![DenyRule { + from: None, // All sources (wildcard) + to: None, // All targets (wildcard) + except: None, + reason: Some("All connections denied".to_string()), + }]), + require: None, + warn: None, + }; + + manager + .load_policies("mortar-test", policy, mappings) + .await; + + // All connections should be denied + let decision = manager + .evaluate_connection("svc-api-1", "svc-db-1") + .await; + assert!(decision.is_denied()); + + let decision = manager + .evaluate_connection("svc-db-1", "svc-api-1") + .await; + assert!(decision.is_denied()); +} + +/// Test isolated mortar projects don't affect each other. +#[tokio::test] +async fn test_mortar_isolation() { + use fabricksd::events::EventBus; + use fabricksd::policy::PolicyManager; + use std::sync::Arc; + + let event_bus = Arc::new(EventBus::new(100, 100)); + let manager = PolicyManager::new(event_bus); + + // Mortar 1: Has a deny rule + let mut mappings1 = HashMap::new(); + mappings1.insert("api".to_string(), "svc-api-1".to_string()); + + let policy1 = Policy { + description: Some("Mortar 1 - restrictive".to_string()), + deny: Some(vec![DenyRule { + from: None, + to: None, + except: None, + reason: Some("Deny all in mortar 1".to_string()), + }]), + require: None, + warn: None, + }; + + // Mortar 2: No deny rules + let mut mappings2 = HashMap::new(); + mappings2.insert("worker".to_string(), "svc-worker-1".to_string()); + + let policy2 = Policy { + description: Some("Mortar 2 - permissive".to_string()), + deny: None, + require: None, + warn: None, + }; + + manager.load_policies("mortar-1", policy1, mappings1).await; + manager.load_policies("mortar-2", policy2, mappings2).await; + + // Service in mortar-1 should be denied + let decision = manager + .evaluate_connection("svc-api-1", "external.com") + .await; + assert!(decision.is_denied()); + + // Service in mortar-2 should be allowed + let decision = manager + .evaluate_connection("svc-worker-1", "external.com") + .await; + assert!(decision.is_allowed()); +} diff --git a/fabricksd/src/api/handlers/mod.rs b/fabricksd/src/api/handlers/mod.rs index a3a3bed..1d96b32 100644 --- a/fabricksd/src/api/handlers/mod.rs +++ b/fabricksd/src/api/handlers/mod.rs @@ -5,5 +5,6 @@ pub mod health; pub mod metrics; pub mod mortar; pub mod networks; +pub mod policies; pub mod services; pub mod volumes; diff --git a/fabricksd/src/api/handlers/policies.rs b/fabricksd/src/api/handlers/policies.rs new file mode 100644 index 0000000..0e45753 --- /dev/null +++ b/fabricksd/src/api/handlers/policies.rs @@ -0,0 +1,79 @@ +//! Policy API handlers. + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use serde::Serialize; + +use crate::api::response::{ApiError, ApiResponse}; +use crate::policy::PolicyInfo; +use crate::state::AppState; + +/// Creates a typed error response. +fn typed_error(error_type: &str, message: String) -> ApiResponse { + ApiResponse::Error { + error: ApiError { + code: error_type.to_string(), + message, + details: None, + }, + } +} + +/// Response with all policies. +#[derive(Debug, Serialize)] +pub struct AllPoliciesResponse { + /// All loaded policies. + pub policies: Vec, + + /// Total number of policies. + pub total: usize, +} + +/// GET `/v1/policies` +/// +/// Lists all loaded policies. +pub async fn list_policies(State(state): State) -> Json> { + let policies = state.policy_manager.list_policies().await; + let total = policies.len(); + + Json(ApiResponse::success(AllPoliciesResponse { policies, total })) +} + +/// GET `/v1/policies/{mortar_id}` +/// +/// Gets a specific policy by mortar ID. +pub async fn get_policy( + State(state): State, + Path(mortar_id): Path, +) -> (StatusCode, Json>) { + match state.policy_manager.get_policy(&mortar_id).await { + Some(policy) => (StatusCode::OK, Json(ApiResponse::success(PolicyInfo::from(&policy)))), + None => ( + StatusCode::NOT_FOUND, + Json(typed_error( + "POLICY_NOT_FOUND", + format!("No policy found for mortar project '{mortar_id}'"), + )), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_all_policies_response_serialization() { + let response = AllPoliciesResponse { + policies: vec![], + total: 0, + }; + + let json = serde_json::to_string(&response).expect("should serialize"); + assert!(json.contains("\"total\":0")); + assert!(json.contains("\"policies\":[]")); + } +} diff --git a/fabricksd/src/api/router.rs b/fabricksd/src/api/router.rs index eee0b82..709ad7f 100644 --- a/fabricksd/src/api/router.rs +++ b/fabricksd/src/api/router.rs @@ -95,6 +95,12 @@ pub fn build_router(state: AppState) -> Router { "/v1/services/{id}/metrics", get(handlers::metrics::get_service_metrics), ) + // Policies + .route("/v1/policies", get(handlers::policies::list_policies)) + .route( + "/v1/policies/{mortar_id}", + get(handlers::policies::get_policy), + ) // Add state .with_state(state) // Add tracing layer for request/response logging diff --git a/fabricksd/src/events/types.rs b/fabricksd/src/events/types.rs index 1c3e116..79275c8 100644 --- a/fabricksd/src/events/types.rs +++ b/fabricksd/src/events/types.rs @@ -47,6 +47,12 @@ pub enum EventType { VolumeCreated, /// A volume was deleted. VolumeDeleted, + + // Policy events + /// A policy was evaluated for a connection. + PolicyEvaluated, + /// A policy violation occurred (connection denied). + PolicyViolation, } /// A daemon event. diff --git a/fabricksd/src/lib.rs b/fabricksd/src/lib.rs index 19c8eac..cbd8686 100644 --- a/fabricksd/src/lib.rs +++ b/fabricksd/src/lib.rs @@ -16,6 +16,7 @@ //! - [`volume`] - Persistent volume management //! - [`proxy`] - HTTP proxy for routing to WASM services //! - [`scaler`] - Metrics collection and auto-scaling +//! - [`policy`] - Policy engine for security policies //! - [`shutdown`] - Graceful shutdown coordination pub mod api; @@ -24,6 +25,7 @@ pub mod error; pub mod events; pub mod health; pub mod network; +pub mod policy; pub mod proxy; pub mod scaler; pub mod service; @@ -39,6 +41,7 @@ pub use events::{Event, EventBus, EventType}; pub use health::{HealthMonitor, HealthMonitorConfig, HealthStatus, ServiceHealth}; pub use network::{NetworkConfig, NetworkManager, ServiceRegistry}; pub use proxy::{EgressProxy, EgressRequest, EgressResponse, ProxyServer, ServiceRouter}; +pub use policy::{PolicyDecision, PolicyInfo, PolicyManager}; pub use scaler::{AutoScaler, AutoScalerConfig, MetricsCollector, MetricsCollectorConfig, ServiceMetrics}; pub use service::{ServiceConfig, ServiceInfo, ServiceManager}; pub use state::AppState; diff --git a/fabricksd/src/network/validation.rs b/fabricksd/src/network/validation.rs index 3c2d4d5..5111ffb 100644 --- a/fabricksd/src/network/validation.rs +++ b/fabricksd/src/network/validation.rs @@ -4,7 +4,7 @@ //! 1. Ingress (external to service): Network access mode (`external` vs `internal`) //! 2. Egress capabilities (can the service connect to this target?) //! 3. Network membership (do both services share a network?) -//! 4. Policy rules (future: additional access control policies) +//! 4. Policy rules (deny, require, warn rules from mortar policies) use std::sync::Arc; @@ -12,6 +12,8 @@ use tracing::debug; use fabricks_common::models::capability::Capabilities; +use crate::policy::{PolicyDecision, PolicyManager}; + use super::manager::NetworkManager; /// Result of connection validation. @@ -69,7 +71,7 @@ impl ConnectionDecision { /// Checks in order: /// 1. Does the service's capabilities allow connecting to this target? /// 2. If internal: do both services share a network? -/// 3. Additional policy rules (future) +/// 3. Policy rules from mortar configuration /// /// # Arguments /// @@ -78,6 +80,7 @@ impl ConnectionDecision { /// * `target_host` - The target hostname (may include port) /// * `target_port` - The target port number /// * `network_manager` - The network manager for service resolution +/// * `policy_manager` - Optional policy manager for policy evaluation /// /// # Returns /// @@ -88,6 +91,7 @@ pub async fn validate_connection( target_host: &str, target_port: u16, network_manager: &Arc, + policy_manager: Option<&PolicyManager>, ) -> ConnectionDecision { // Step 1: Check capability grant let target_with_port = format!("{target_host}:{target_port}"); @@ -119,12 +123,45 @@ pub async fn validate_connection( }; } + // Step 3: Check policies for internal connections + if let Some(pm) = policy_manager { + let decision = pm + .evaluate_connection(from_service_id, &target_service_id) + .await; + + match decision { + PolicyDecision::Deny { reason, .. } => { + return ConnectionDecision::DenyPolicy { reason }; + } + PolicyDecision::Warn { .. } => { + // Warning already logged by engine, continue to allow + } + PolicyDecision::Allow => {} + } + } + return ConnectionDecision::AllowInternal { service_id: target_service_id, }; } - // Step 3: External connection - already validated by capability + // Step 4: External connection - check policies + if let Some(pm) = policy_manager { + let decision = pm + .evaluate_connection(from_service_id, &target_with_port) + .await; + + match decision { + PolicyDecision::Deny { reason, .. } => { + return ConnectionDecision::DenyPolicy { reason }; + } + PolicyDecision::Warn { .. } => { + // Warning already logged by engine, continue to allow + } + PolicyDecision::Allow => {} + } + } + debug!( from = %from_service_id, host = %target_host, @@ -272,7 +309,8 @@ mod tests { async fn test_deny_no_capability() { let manager = create_test_network_manager(); - let decision = validate_connection("svc-1", &None, "api.example.com", 443, &manager).await; + let decision = + validate_connection("svc-1", &None, "api.example.com", 443, &manager, None).await; assert!(matches!( decision, @@ -286,7 +324,8 @@ mod tests { let manager = create_test_network_manager(); let cap = capabilities_for_connect(vec!["other.example.com:443"]); - let decision = validate_connection("svc-1", &cap, "api.example.com", 443, &manager).await; + let decision = + validate_connection("svc-1", &cap, "api.example.com", 443, &manager, None).await; assert!(matches!( decision, @@ -299,7 +338,8 @@ mod tests { let manager = create_test_network_manager(); let cap = capabilities_for_connect(vec!["api.example.com:443"]); - let decision = validate_connection("svc-1", &cap, "api.example.com", 443, &manager).await; + let decision = + validate_connection("svc-1", &cap, "api.example.com", 443, &manager, None).await; assert_eq!(decision, ConnectionDecision::AllowExternal); assert!(decision.is_allowed()); @@ -310,7 +350,8 @@ mod tests { let manager = create_test_network_manager(); let cap = capabilities_allow_all_outbound(); - let decision = validate_connection("svc-1", &cap, "any.host.com", 8080, &manager).await; + let decision = + validate_connection("svc-1", &cap, "any.host.com", 8080, &manager, None).await; assert_eq!(decision, ConnectionDecision::AllowExternal); } @@ -338,7 +379,8 @@ mod tests { let cap = capabilities_for_connect(vec!["service-b:8080"]); - let decision = validate_connection("svc-a", &cap, "service-b", 8080, &manager).await; + let decision = + validate_connection("svc-a", &cap, "service-b", 8080, &manager, None).await; assert!(matches!(decision, ConnectionDecision::DenyNetwork { .. })); } @@ -363,7 +405,8 @@ mod tests { let cap = capabilities_for_connect(vec!["service-b:8080"]); - let decision = validate_connection("svc-a", &cap, "service-b", 8080, &manager).await; + let decision = + validate_connection("svc-a", &cap, "service-b", 8080, &manager, None).await; match decision { ConnectionDecision::AllowInternal { service_id } => { diff --git a/fabricksd/src/policy/engine.rs b/fabricksd/src/policy/engine.rs new file mode 100644 index 0000000..211e9d7 --- /dev/null +++ b/fabricksd/src/policy/engine.rs @@ -0,0 +1,578 @@ +//! Policy evaluation engine. +//! +//! Evaluates policies against connection requests, checking deny, require, +//! and warn rules in order. + +use std::sync::Arc; + +use fabricks_common::models::mortar::{DenyRule, RequireRule, WarnRule}; +use tracing::{debug, warn}; + +use crate::events::{Event, EventBus, EventType}; + +use super::types::{EvaluatedPolicy, PolicyDecision, PolicyEvaluationContext}; + +/// Policy evaluation engine. +/// +/// Evaluates policies against connection requests and emits audit events. +pub struct PolicyEngine { + /// Event bus for audit logging. + event_bus: Arc, +} + +impl PolicyEngine { + /// Creates a new policy engine. + #[must_use] + pub fn new(event_bus: Arc) -> Self { + Self { event_bus } + } + + /// Evaluates policies against a connection request. + /// + /// Rules are evaluated in this order: + /// 1. Deny rules - if any match, the connection is denied + /// 2. Require rules - if any requirements aren't met, connection is denied + /// 3. Warn rules - if any match, a warning is logged but connection is allowed + /// 4. If no rules match, the connection is allowed + pub fn evaluate( + &self, + ctx: &PolicyEvaluationContext, + policies: &[EvaluatedPolicy], + ) -> PolicyDecision { + debug!( + from = %ctx.from_service, + to = %ctx.to_target, + policies = policies.len(), + "Evaluating policies for connection" + ); + + for policy in policies { + // Check deny rules first + if let Some(ref deny_rules) = policy.policy.deny { + for (idx, rule) in deny_rules.iter().enumerate() { + if self.matches_deny_rule(rule, ctx, policy) { + let description = format!( + "deny rule {} in {}", + idx + 1, + policy.mortar_id + ); + let reason = rule + .reason + .clone() + .unwrap_or_else(|| "denied by policy".to_string()); + + let decision = PolicyDecision::deny(&description, &reason); + self.emit_policy_event(ctx, &decision); + return decision; + } + } + } + + // Check require rules + if let Some(ref require_rules) = policy.policy.require { + for (idx, rule) in require_rules.iter().enumerate() { + if let Some(reason) = self.check_require_rule(rule, ctx, policy) { + let description = format!( + "require rule {} in {}", + idx + 1, + policy.mortar_id + ); + + let decision = PolicyDecision::deny(&description, &reason); + self.emit_policy_event(ctx, &decision); + return decision; + } + } + } + + // Check warn rules + if let Some(ref warn_rules) = policy.policy.warn { + for (idx, rule) in warn_rules.iter().enumerate() { + if let Some(reason) = self.matches_warn_rule(rule, ctx, policy) { + let description = format!( + "warn rule {} in {}", + idx + 1, + policy.mortar_id + ); + + warn!( + from = %ctx.from_service, + to = %ctx.to_target, + rule = %description, + reason = %reason, + "Policy warning triggered" + ); + + let decision = PolicyDecision::warn(&description, &reason); + self.emit_policy_event(ctx, &decision); + return decision; + } + } + } + } + + // No rules matched, allow + let decision = PolicyDecision::Allow; + self.emit_policy_event(ctx, &decision); + decision + } + + /// Checks if a deny rule matches the connection. + fn matches_deny_rule( + &self, + rule: &DenyRule, + ctx: &PolicyEvaluationContext, + policy: &EvaluatedPolicy, + ) -> bool { + // Check if source matches "from" (if specified) + let from_matches = match &rule.from { + Some(froms) => self.matches_service_list(froms, &ctx.from_service, policy), + None => true, // No "from" means all sources + }; + + if !from_matches { + return false; + } + + // Check if target matches "to" (if specified) + let to_matches = match &rule.to { + Some(tos) => self.matches_target_list(tos, &ctx.to_target, policy), + None => true, // No "to" means all targets + }; + + if !to_matches { + return false; + } + + // Check exceptions + if let Some(ref exceptions) = rule.except { + // If either source or target is in exceptions, don't match + if self.matches_service_list(exceptions, &ctx.from_service, policy) { + return false; + } + if self.matches_target_list(exceptions, &ctx.to_target, policy) { + return false; + } + } + + true + } + + /// Checks if a require rule is violated and returns the reason if so. + fn check_require_rule( + &self, + rule: &RequireRule, + ctx: &PolicyEvaluationContext, + policy: &EvaluatedPolicy, + ) -> Option { + // Check if this rule applies to the source service + let applies = match &rule.services { + Some(services) => self.matches_service_list(services, &ctx.from_service, policy), + None => true, // Applies to all services in the mortar + }; + + if !applies { + return None; + } + + // For now, we can check audit requirement (logging is always on) + // TLS and encryption would need connection metadata we don't have here + // These could be enforced at connection time with additional context + + if rule.tls == Some(true) { + // We can't enforce TLS at policy level without connection context + // This would be checked during actual connection establishment + debug!("TLS requirement noted but cannot be enforced at policy level"); + } + + if rule.encryption == Some(true) { + // Similar to TLS + debug!("Encryption requirement noted but cannot be enforced at policy level"); + } + + // Audit is always enabled when policy is evaluated + // rule.audit is implicitly satisfied + + None + } + + /// Checks if a warn rule matches and returns the warning reason if so. + fn matches_warn_rule( + &self, + rule: &WarnRule, + ctx: &PolicyEvaluationContext, + policy: &EvaluatedPolicy, + ) -> Option { + // Check for cross-network warning + if rule.cross_network == Some(true) { + // A connection is cross-network if: + // - Target is not in the same mortar project + // - OR target is an external address + + let target_in_mortar = self.is_target_in_mortar(&ctx.to_target, policy); + let is_external = self.is_external_target(&ctx.to_target); + + if !target_in_mortar || is_external { + // Check exceptions + if let Some(ref exceptions) = rule.except { + if self.matches_target_list(exceptions, &ctx.to_target, policy) { + return None; + } + } + + return Some("cross-network communication detected".to_string()); + } + } + + None + } + + /// Checks if a service ID matches any entry in the list. + fn matches_service_list( + &self, + list: &[String], + service_id: &str, + policy: &EvaluatedPolicy, + ) -> bool { + // Get the service name for this ID + let service_name = policy.service_name(service_id); + + for entry in list { + // Direct service ID match + if entry == service_id { + return true; + } + + // Service name match + if let Some(name) = service_name { + if entry == name { + return true; + } + } + + // Wildcard match + if entry == "*" { + return true; + } + } + + false + } + + /// Checks if a target matches any entry in the list. + fn matches_target_list( + &self, + list: &[String], + target: &str, + policy: &EvaluatedPolicy, + ) -> bool { + for entry in list { + // Direct target match + if entry == target { + return true; + } + + // Check if target is a service name in this mortar + if let Some(target_id) = policy.service_id(entry) { + if target_id == target { + return true; + } + } + + // Wildcard match + if entry == "*" { + return true; + } + + // Check if it's a service name and the target matches the ID + if policy.service_id(target).is_some() { + // Target is a service name, check if entry matches its ID + if let Some(target_id) = policy.service_id(target) { + if entry == target_id { + return true; + } + } + } + } + + false + } + + /// Checks if a target is within the same mortar project. + fn is_target_in_mortar(&self, target: &str, policy: &EvaluatedPolicy) -> bool { + // Check if target is a service ID in this mortar + if policy.contains_service(target) { + return true; + } + + // Check if target is a service name in this mortar + if policy.service_id(target).is_some() { + return true; + } + + false + } + + /// Checks if a target is an external address (not a service). + fn is_external_target(&self, target: &str) -> bool { + // External targets typically contain: + // - URLs (http://, https://) + // - IP addresses + // - Domain names with ports + + target.contains("://") + || target.contains(':') + || target.contains('.') + || target.starts_with("http") + } + + /// Emits a policy evaluation event. + fn emit_policy_event(&self, ctx: &PolicyEvaluationContext, decision: &PolicyDecision) { + let event = Event::new( + EventType::PolicyEvaluated, + serde_json::json!({ + "from_service": ctx.from_service, + "to_target": ctx.to_target, + "mortar_id": ctx.mortar_id, + "decision": decision.decision_type(), + "rule_description": decision.rule_description(), + "reason": decision.reason() + }), + ); + + // Spawn task to publish event without blocking + let event_bus = Arc::clone(&self.event_bus); + tokio::spawn(async move { + event_bus.publish(event).await; + }); + + // For denials, also emit a violation event + if decision.is_denied() { + let violation_event = Event::new( + EventType::PolicyViolation, + serde_json::json!({ + "from_service": ctx.from_service, + "to_target": ctx.to_target, + "mortar_id": ctx.mortar_id, + "rule_description": decision.rule_description(), + "reason": decision.reason() + }), + ); + + let event_bus = Arc::clone(&self.event_bus); + tokio::spawn(async move { + event_bus.publish(violation_event).await; + }); + } + } +} + +impl std::fmt::Debug for PolicyEngine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PolicyEngine").finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use fabricks_common::models::mortar::Policy; + + use crate::events::EventBus; + + use super::*; + + fn test_engine() -> PolicyEngine { + let event_bus = Arc::new(EventBus::new(100, 100)); + PolicyEngine::new(event_bus) + } + + fn test_policy_with_deny(from: Option>, to: Option>) -> EvaluatedPolicy { + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + mappings.insert("db".to_string(), "svc-db-1".to_string()); + mappings.insert("cache".to_string(), "svc-cache-1".to_string()); + + let policy = Policy { + description: Some("Test policy".to_string()), + deny: Some(vec![DenyRule { + from: from.map(|v| v.into_iter().map(String::from).collect()), + to: to.map(|v| v.into_iter().map(String::from).collect()), + except: None, + reason: Some("Test denial".to_string()), + }]), + require: None, + warn: None, + }; + + EvaluatedPolicy::new("mortar-1", policy, mappings) + } + + #[tokio::test] + async fn test_allow_when_no_rules() { + let engine = test_engine(); + let ctx = PolicyEvaluationContext::new("svc-api-1", "svc-db-1"); + let policies = vec![]; + + let decision = engine.evaluate(&ctx, &policies); + assert!(decision.is_allowed()); + } + + #[tokio::test] + async fn test_deny_all_to_all() { + let engine = test_engine(); + let policy = test_policy_with_deny(None, None); + let ctx = PolicyEvaluationContext::new("svc-api-1", "svc-db-1"); + + let decision = engine.evaluate(&ctx, &[policy]); + assert!(decision.is_denied()); + } + + #[tokio::test] + async fn test_deny_specific_from() { + let engine = test_engine(); + let policy = test_policy_with_deny(Some(vec!["api"]), None); + + // Connection from api should be denied + let ctx = PolicyEvaluationContext::new("svc-api-1", "svc-db-1"); + let decision = engine.evaluate(&ctx, &[policy.clone()]); + assert!(decision.is_denied()); + + // Connection from cache should be allowed + let ctx = PolicyEvaluationContext::new("svc-cache-1", "svc-db-1"); + let decision = engine.evaluate(&ctx, &[policy]); + assert!(decision.is_allowed()); + } + + #[tokio::test] + async fn test_deny_specific_to() { + let engine = test_engine(); + let policy = test_policy_with_deny(None, Some(vec!["db"])); + + // Connection to db should be denied + let ctx = PolicyEvaluationContext::new("svc-api-1", "svc-db-1"); + let decision = engine.evaluate(&ctx, &[policy.clone()]); + assert!(decision.is_denied()); + + // Connection to cache should be allowed + let ctx = PolicyEvaluationContext::new("svc-api-1", "svc-cache-1"); + let decision = engine.evaluate(&ctx, &[policy]); + assert!(decision.is_allowed()); + } + + #[tokio::test] + async fn test_deny_from_to_combination() { + let engine = test_engine(); + let policy = test_policy_with_deny(Some(vec!["api"]), Some(vec!["db"])); + + // api -> db should be denied + let ctx = PolicyEvaluationContext::new("svc-api-1", "svc-db-1"); + let decision = engine.evaluate(&ctx, &[policy.clone()]); + assert!(decision.is_denied()); + + // api -> cache should be allowed + let ctx = PolicyEvaluationContext::new("svc-api-1", "svc-cache-1"); + let decision = engine.evaluate(&ctx, &[policy.clone()]); + assert!(decision.is_allowed()); + + // cache -> db should be allowed + let ctx = PolicyEvaluationContext::new("svc-cache-1", "svc-db-1"); + let decision = engine.evaluate(&ctx, &[policy]); + assert!(decision.is_allowed()); + } + + #[tokio::test] + async fn test_deny_with_exception() { + let engine = test_engine(); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + mappings.insert("db".to_string(), "svc-db-1".to_string()); + mappings.insert("admin".to_string(), "svc-admin-1".to_string()); + + let policy = Policy { + description: None, + deny: Some(vec![DenyRule { + from: None, + to: Some(vec!["db".to_string()]), + except: Some(vec!["admin".to_string()]), + reason: Some("Only admin can access db".to_string()), + }]), + require: None, + warn: None, + }; + + let ep = EvaluatedPolicy::new("mortar-1", policy, mappings); + + // api -> db should be denied + let ctx = PolicyEvaluationContext::new("svc-api-1", "svc-db-1"); + let decision = engine.evaluate(&ctx, &[ep.clone()]); + assert!(decision.is_denied()); + + // admin -> db should be allowed (exception) + let ctx = PolicyEvaluationContext::new("svc-admin-1", "svc-db-1"); + let decision = engine.evaluate(&ctx, &[ep]); + assert!(decision.is_allowed()); + } + + #[tokio::test] + async fn test_warn_cross_network() { + let engine = test_engine(); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + + let policy = Policy { + description: None, + deny: None, + require: None, + warn: Some(vec![WarnRule { + cross_network: Some(true), + except: None, + }]), + }; + + let ep = EvaluatedPolicy::new("mortar-1", policy, mappings); + + // Connection to external should warn + let ctx = PolicyEvaluationContext::new("svc-api-1", "https://api.example.com"); + let decision = engine.evaluate(&ctx, &[ep.clone()]); + assert!(matches!(decision, PolicyDecision::Warn { .. })); + + // Connection within mortar should allow + let ctx = PolicyEvaluationContext::new("svc-api-1", "svc-api-1"); + let decision = engine.evaluate(&ctx, &[ep]); + assert!(decision.is_allowed()); + } + + #[tokio::test] + async fn test_warn_with_exception() { + let engine = test_engine(); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + + let policy = Policy { + description: None, + deny: None, + require: None, + warn: Some(vec![WarnRule { + cross_network: Some(true), + except: Some(vec!["https://allowed.example.com".to_string()]), + }]), + }; + + let ep = EvaluatedPolicy::new("mortar-1", policy, mappings); + + // Connection to non-excepted external should warn + let ctx = PolicyEvaluationContext::new("svc-api-1", "https://api.example.com"); + let decision = engine.evaluate(&ctx, &[ep.clone()]); + assert!(matches!(decision, PolicyDecision::Warn { .. })); + + // Connection to excepted external should allow + let ctx = PolicyEvaluationContext::new("svc-api-1", "https://allowed.example.com"); + let decision = engine.evaluate(&ctx, &[ep]); + assert!(decision.is_allowed()); + } +} diff --git a/fabricksd/src/policy/manager.rs b/fabricksd/src/policy/manager.rs new file mode 100644 index 0000000..e946de0 --- /dev/null +++ b/fabricksd/src/policy/manager.rs @@ -0,0 +1,384 @@ +//! Policy lifecycle management. +//! +//! Manages loading, unloading, and querying policies for mortar projects. + +use std::collections::HashMap; +use std::sync::Arc; + +use fabricks_common::models::mortar::Policy; +use tokio::sync::RwLock; +use tracing::{debug, info}; + +use crate::events::EventBus; + +use super::engine::PolicyEngine; +use super::types::{EvaluatedPolicy, PolicyDecision, PolicyEvaluationContext, PolicyInfo}; + +/// Manages policies for mortar projects. +/// +/// Handles loading and unloading policies, and delegates evaluation +/// to the policy engine. +pub struct PolicyManager { + /// Policy evaluation engine. + engine: PolicyEngine, + + /// Loaded policies indexed by mortar ID. + policies: RwLock>, + + /// Mapping from service IDs to mortar IDs. + service_to_mortar: RwLock>, +} + +impl PolicyManager { + /// Creates a new policy manager. + #[must_use] + pub fn new(event_bus: Arc) -> Self { + Self { + engine: PolicyEngine::new(event_bus), + policies: RwLock::new(HashMap::new()), + service_to_mortar: RwLock::new(HashMap::new()), + } + } + + /// Loads a policy for a mortar project. + /// + /// The service mappings map service names (as used in the mortar file) + /// to service IDs (as used internally by the daemon). + pub async fn load_policies( + &self, + mortar_id: &str, + policy: Policy, + service_mappings: HashMap, + ) { + info!( + mortar_id = %mortar_id, + deny_rules = policy.deny.as_ref().map_or(0, Vec::len), + require_rules = policy.require.as_ref().map_or(0, Vec::len), + warn_rules = policy.warn.as_ref().map_or(0, Vec::len), + services = service_mappings.len(), + "Loading policy for mortar project" + ); + + // Create evaluated policy + let evaluated = EvaluatedPolicy::new(mortar_id, policy, service_mappings.clone()); + + // Store the policy + { + let mut policies = self.policies.write().await; + policies.insert(mortar_id.to_string(), evaluated); + } + + // Update service-to-mortar mapping + { + let mut s2m = self.service_to_mortar.write().await; + for service_id in service_mappings.values() { + s2m.insert(service_id.clone(), mortar_id.to_string()); + } + } + + debug!(mortar_id = %mortar_id, "Policy loaded successfully"); + } + + /// Unloads a policy for a mortar project. + pub async fn unload_policies(&self, mortar_id: &str) { + info!(mortar_id = %mortar_id, "Unloading policy for mortar project"); + + // Remove the policy + let removed = { + let mut policies = self.policies.write().await; + policies.remove(mortar_id) + }; + + // Clean up service-to-mortar mappings + if let Some(policy) = removed { + let mut s2m = self.service_to_mortar.write().await; + for service_id in policy.service_mappings.values() { + s2m.remove(service_id); + } + } + + debug!(mortar_id = %mortar_id, "Policy unloaded successfully"); + } + + /// Evaluates a connection request against applicable policies. + /// + /// Returns the policy decision for the connection. + pub async fn evaluate_connection( + &self, + from_service: &str, + to_target: &str, + ) -> PolicyDecision { + // Find the mortar ID for this service + let mortar_id = { + let s2m = self.service_to_mortar.read().await; + s2m.get(from_service).cloned() + }; + + // Build evaluation context + let mut ctx = PolicyEvaluationContext::new(from_service, to_target); + if let Some(ref mid) = mortar_id { + ctx = ctx.with_mortar_id(mid); + } + + // Get applicable policies + let policies = self.get_applicable_policies(from_service).await; + + // If no policies apply, allow + if policies.is_empty() { + debug!( + from = %from_service, + to = %to_target, + "No policies applicable, allowing connection" + ); + return PolicyDecision::Allow; + } + + // Evaluate against all applicable policies + self.engine.evaluate(&ctx, &policies) + } + + /// Gets policies applicable to a service. + /// + /// Currently, only the policy for the service's mortar project applies. + async fn get_applicable_policies(&self, service_id: &str) -> Vec { + // Find the mortar ID for this service + let mortar_id = { + let s2m = self.service_to_mortar.read().await; + s2m.get(service_id).cloned() + }; + + // Get the policy for that mortar + let Some(mortar_id) = mortar_id else { + return vec![]; + }; + + let policies = self.policies.read().await; + policies.get(&mortar_id).cloned().into_iter().collect() + } + + /// Gets a policy by mortar ID. + pub async fn get_policy(&self, mortar_id: &str) -> Option { + let policies = self.policies.read().await; + policies.get(mortar_id).cloned() + } + + /// Lists all loaded policies. + pub async fn list_policies(&self) -> Vec { + let policies = self.policies.read().await; + policies.values().map(PolicyInfo::from).collect() + } + + /// Gets the mortar ID for a service. + pub async fn get_mortar_id(&self, service_id: &str) -> Option { + let s2m = self.service_to_mortar.read().await; + s2m.get(service_id).cloned() + } + + /// Checks if a service has any policies that apply to it. + pub async fn has_policies(&self, service_id: &str) -> bool { + let s2m = self.service_to_mortar.read().await; + if let Some(mortar_id) = s2m.get(service_id) { + let policies = self.policies.read().await; + return policies.contains_key(mortar_id); + } + false + } +} + +impl std::fmt::Debug for PolicyManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PolicyManager").finish_non_exhaustive() + } +} + +/// Shared reference to a policy manager. +pub type SharedPolicyManager = Arc; + +#[cfg(test)] +mod tests { + use fabricks_common::models::mortar::DenyRule; + + use crate::events::EventBus; + + use super::*; + + fn test_manager() -> PolicyManager { + let event_bus = Arc::new(EventBus::new(100, 100)); + PolicyManager::new(event_bus) + } + + #[tokio::test] + async fn test_load_and_unload_policy() { + let manager = test_manager(); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + + let policy = Policy { + description: Some("Test".to_string()), + deny: None, + require: None, + warn: None, + }; + + // Load policy + manager + .load_policies("mortar-1", policy, mappings) + .await; + + // Verify loaded + assert!(manager.get_policy("mortar-1").await.is_some()); + assert_eq!(manager.get_mortar_id("svc-api-1").await, Some("mortar-1".to_string())); + + // Unload policy + manager.unload_policies("mortar-1").await; + + // Verify unloaded + assert!(manager.get_policy("mortar-1").await.is_none()); + assert!(manager.get_mortar_id("svc-api-1").await.is_none()); + } + + #[tokio::test] + async fn test_list_policies() { + let manager = test_manager(); + + // Initially empty + assert!(manager.list_policies().await.is_empty()); + + // Load a policy + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + + let policy = Policy { + description: Some("Test".to_string()), + deny: None, + require: None, + warn: None, + }; + + manager + .load_policies("mortar-1", policy, mappings) + .await; + + // Should have one policy + let policies = manager.list_policies().await; + assert_eq!(policies.len(), 1); + assert_eq!(policies[0].mortar_id, "mortar-1"); + } + + #[tokio::test] + async fn test_evaluate_connection_no_policy() { + let manager = test_manager(); + + // No policies loaded, should allow + let decision = manager + .evaluate_connection("svc-unknown", "svc-other") + .await; + assert!(decision.is_allowed()); + } + + #[tokio::test] + async fn test_evaluate_connection_with_deny() { + let manager = test_manager(); + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + mappings.insert("db".to_string(), "svc-db-1".to_string()); + + let policy = Policy { + description: None, + deny: Some(vec![DenyRule { + from: Some(vec!["api".to_string()]), + to: Some(vec!["db".to_string()]), + except: None, + reason: Some("API cannot directly access DB".to_string()), + }]), + require: None, + warn: None, + }; + + manager + .load_policies("mortar-1", policy, mappings) + .await; + + // api -> db should be denied + let decision = manager + .evaluate_connection("svc-api-1", "svc-db-1") + .await; + assert!(decision.is_denied()); + assert!(decision.reason().unwrap().contains("API cannot directly access DB")); + } + + #[tokio::test] + async fn test_has_policies() { + let manager = test_manager(); + + // No policy initially + assert!(!manager.has_policies("svc-api-1").await); + + // Load a policy + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-1".to_string()); + + let policy = Policy { + description: None, + deny: None, + require: None, + warn: None, + }; + + manager + .load_policies("mortar-1", policy, mappings) + .await; + + // Now has policy + assert!(manager.has_policies("svc-api-1").await); + assert!(!manager.has_policies("svc-unknown").await); + } + + #[tokio::test] + async fn test_multiple_mortars() { + let manager = test_manager(); + + // Load two mortars with different policies + let mut mappings1 = HashMap::new(); + mappings1.insert("api".to_string(), "svc-api-1".to_string()); + + let policy1 = Policy { + description: Some("Mortar 1".to_string()), + deny: None, + require: None, + warn: None, + }; + + let mut mappings2 = HashMap::new(); + mappings2.insert("worker".to_string(), "svc-worker-1".to_string()); + + let policy2 = Policy { + description: Some("Mortar 2".to_string()), + deny: Some(vec![DenyRule { + from: None, + to: None, + except: None, + reason: Some("Deny all".to_string()), + }]), + require: None, + warn: None, + }; + + manager.load_policies("mortar-1", policy1, mappings1).await; + manager.load_policies("mortar-2", policy2, mappings2).await; + + // api (mortar-1) should be allowed to connect anywhere + let decision = manager + .evaluate_connection("svc-api-1", "external.com") + .await; + assert!(decision.is_allowed()); + + // worker (mortar-2) should be denied + let decision = manager + .evaluate_connection("svc-worker-1", "external.com") + .await; + assert!(decision.is_denied()); + } +} diff --git a/fabricksd/src/policy/mod.rs b/fabricksd/src/policy/mod.rs new file mode 100644 index 0000000..9149156 --- /dev/null +++ b/fabricksd/src/policy/mod.rs @@ -0,0 +1,37 @@ +//! Policy engine for enforcing security policies. +//! +//! This module provides policy evaluation for connections between services, +//! enforcing deny, require, and warn rules defined in mortar files. +//! +//! # Architecture +//! +//! - [`PolicyManager`] manages policy lifecycle (loading, unloading) +//! - [`PolicyEngine`] evaluates rules against connection requests +//! - [`PolicyDecision`] represents the outcome of policy evaluation +//! +//! # Usage +//! +//! Policies are automatically loaded when a mortar project is deployed and +//! unloaded when it's torn down. The policy manager is called during +//! connection validation to enforce policies. +//! +//! ```ignore +//! // Load policies for a mortar project +//! policy_manager.load_policies(mortar_id, policy, service_mappings).await; +//! +//! // Evaluate a connection +//! let decision = policy_manager.evaluate_connection(from_service, to_target).await; +//! match decision { +//! PolicyDecision::Allow => { /* proceed */ } +//! PolicyDecision::Deny { reason, .. } => { /* block connection */ } +//! PolicyDecision::Warn { .. } => { /* log warning, proceed */ } +//! } +//! ``` + +mod engine; +mod manager; +mod types; + +pub use engine::PolicyEngine; +pub use manager::{PolicyManager, SharedPolicyManager}; +pub use types::{EvaluatedPolicy, PolicyDecision, PolicyEvaluationContext, PolicyInfo}; diff --git a/fabricksd/src/policy/types.rs b/fabricksd/src/policy/types.rs new file mode 100644 index 0000000..a674896 --- /dev/null +++ b/fabricksd/src/policy/types.rs @@ -0,0 +1,341 @@ +//! Types for policy evaluation. +//! +//! Defines the types used for policy decisions, evaluation context, +//! and policy storage. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use fabricks_common::models::mortar::Policy; +use serde::{Deserialize, Serialize}; + +/// Result of evaluating a policy against a connection request. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case", tag = "decision")] +pub enum PolicyDecision { + /// Connection is allowed. + Allow, + /// Connection is denied by policy. + Deny { + /// Description of the rule that caused the denial. + rule_description: String, + /// Reason for the denial. + reason: String, + }, + /// Connection is allowed but a warning is logged. + Warn { + /// Description of the rule that triggered the warning. + rule_description: String, + /// Reason for the warning. + reason: String, + }, +} + +impl PolicyDecision { + /// Creates a new deny decision. + #[must_use] + pub fn deny(rule_description: impl Into, reason: impl Into) -> Self { + Self::Deny { + rule_description: rule_description.into(), + reason: reason.into(), + } + } + + /// Creates a new warn decision. + #[must_use] + pub fn warn(rule_description: impl Into, reason: impl Into) -> Self { + Self::Warn { + rule_description: rule_description.into(), + reason: reason.into(), + } + } + + /// Returns true if this decision allows the connection. + #[must_use] + pub fn is_allowed(&self) -> bool { + matches!(self, Self::Allow | Self::Warn { .. }) + } + + /// Returns true if this decision denies the connection. + #[must_use] + pub fn is_denied(&self) -> bool { + matches!(self, Self::Deny { .. }) + } + + /// Returns the decision type as a string. + #[must_use] + pub fn decision_type(&self) -> &'static str { + match self { + Self::Allow => "allow", + Self::Deny { .. } => "deny", + Self::Warn { .. } => "warn", + } + } + + /// Returns the rule description if this is a deny or warn decision. + #[must_use] + pub fn rule_description(&self) -> Option<&str> { + match self { + Self::Allow => None, + Self::Deny { + rule_description, .. + } => Some(rule_description), + Self::Warn { + rule_description, .. + } => Some(rule_description), + } + } + + /// Returns the reason if this is a deny or warn decision. + #[must_use] + pub fn reason(&self) -> Option<&str> { + match self { + Self::Allow => None, + Self::Deny { reason, .. } => Some(reason), + Self::Warn { reason, .. } => Some(reason), + } + } +} + +/// Context for policy evaluation. +/// +/// Contains all information needed to evaluate policies against a connection. +#[derive(Debug, Clone)] +pub struct PolicyEvaluationContext { + /// Service ID initiating the connection. + pub from_service: String, + + /// Target of the connection (service name, URL, or address). + pub to_target: String, + + /// Mortar project ID the source service belongs to (if any). + pub mortar_id: Option, + + /// Timestamp of the evaluation. + pub timestamp: DateTime, +} + +impl PolicyEvaluationContext { + /// Creates a new evaluation context. + #[must_use] + pub fn new(from_service: impl Into, to_target: impl Into) -> Self { + Self { + from_service: from_service.into(), + to_target: to_target.into(), + mortar_id: None, + timestamp: Utc::now(), + } + } + + /// Sets the mortar project ID. + #[must_use] + pub fn with_mortar_id(mut self, mortar_id: impl Into) -> Self { + self.mortar_id = Some(mortar_id.into()); + self + } +} + +/// A policy loaded from a mortar file with service mappings. +/// +/// This stores the policy along with mappings from service names +/// (as used in the mortar file) to service IDs (as used internally). +#[derive(Debug, Clone)] +pub struct EvaluatedPolicy { + /// Mortar project ID. + pub mortar_id: String, + + /// The policy from the mortar file. + pub policy: Policy, + + /// Mapping from service names to service IDs. + pub service_mappings: HashMap, + + /// Reverse mapping from service IDs to service names. + pub id_to_name: HashMap, +} + +impl EvaluatedPolicy { + /// Creates a new evaluated policy. + #[must_use] + pub fn new( + mortar_id: impl Into, + policy: Policy, + service_mappings: HashMap, + ) -> Self { + // Build reverse mapping + let id_to_name: HashMap = service_mappings + .iter() + .map(|(name, id)| (id.clone(), name.clone())) + .collect(); + + Self { + mortar_id: mortar_id.into(), + policy, + service_mappings, + id_to_name, + } + } + + /// Gets the service name for a given service ID. + #[must_use] + pub fn service_name(&self, service_id: &str) -> Option<&str> { + self.id_to_name.get(service_id).map(String::as_str) + } + + /// Gets the service ID for a given service name. + #[must_use] + pub fn service_id(&self, service_name: &str) -> Option<&str> { + self.service_mappings.get(service_name).map(String::as_str) + } + + /// Checks if a service ID belongs to this mortar project. + #[must_use] + pub fn contains_service(&self, service_id: &str) -> bool { + self.id_to_name.contains_key(service_id) + } +} + +/// API response for a policy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PolicyInfo { + /// Mortar project ID. + pub mortar_id: String, + + /// Policy description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Number of deny rules. + pub deny_rules: usize, + + /// Number of require rules. + pub require_rules: usize, + + /// Number of warn rules. + pub warn_rules: usize, + + /// Services covered by this policy. + pub services: Vec, +} + +impl From<&EvaluatedPolicy> for PolicyInfo { + fn from(ep: &EvaluatedPolicy) -> Self { + Self { + mortar_id: ep.mortar_id.clone(), + description: ep.policy.description.clone(), + deny_rules: ep.policy.deny.as_ref().map_or(0, Vec::len), + require_rules: ep.policy.require.as_ref().map_or(0, Vec::len), + warn_rules: ep.policy.warn.as_ref().map_or(0, Vec::len), + services: ep.service_mappings.keys().cloned().collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_policy_decision_allow() { + let decision = PolicyDecision::Allow; + assert!(decision.is_allowed()); + assert!(!decision.is_denied()); + assert_eq!(decision.decision_type(), "allow"); + assert!(decision.rule_description().is_none()); + assert!(decision.reason().is_none()); + } + + #[test] + fn test_policy_decision_deny() { + let decision = PolicyDecision::deny("deny rule 1", "connection not allowed"); + assert!(!decision.is_allowed()); + assert!(decision.is_denied()); + assert_eq!(decision.decision_type(), "deny"); + assert_eq!(decision.rule_description(), Some("deny rule 1")); + assert_eq!(decision.reason(), Some("connection not allowed")); + } + + #[test] + fn test_policy_decision_warn() { + let decision = PolicyDecision::warn("warn rule 1", "cross-network communication"); + assert!(decision.is_allowed()); + assert!(!decision.is_denied()); + assert_eq!(decision.decision_type(), "warn"); + assert_eq!(decision.rule_description(), Some("warn rule 1")); + assert_eq!(decision.reason(), Some("cross-network communication")); + } + + #[test] + fn test_evaluation_context() { + let ctx = PolicyEvaluationContext::new("svc-1", "svc-2").with_mortar_id("mortar-1"); + + assert_eq!(ctx.from_service, "svc-1"); + assert_eq!(ctx.to_target, "svc-2"); + assert_eq!(ctx.mortar_id, Some("mortar-1".to_string())); + } + + #[test] + fn test_evaluated_policy() { + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-api-123".to_string()); + mappings.insert("db".to_string(), "svc-db-456".to_string()); + + let policy = Policy { + description: Some("Test policy".to_string()), + deny: None, + require: None, + warn: None, + }; + + let ep = EvaluatedPolicy::new("mortar-1", policy, mappings); + + assert_eq!(ep.service_name("svc-api-123"), Some("api")); + assert_eq!(ep.service_id("api"), Some("svc-api-123")); + assert!(ep.contains_service("svc-api-123")); + assert!(!ep.contains_service("svc-unknown")); + } + + #[test] + fn test_policy_info_from_evaluated_policy() { + use fabricks_common::models::mortar::{DenyRule, RequireRule}; + + let mut mappings = HashMap::new(); + mappings.insert("api".to_string(), "svc-1".to_string()); + + let policy = Policy { + description: Some("Test".to_string()), + deny: Some(vec![ + DenyRule { + from: None, + to: None, + except: None, + reason: None, + }, + DenyRule { + from: None, + to: None, + except: None, + reason: None, + }, + ]), + require: Some(vec![RequireRule { + networks: None, + services: None, + tls: Some(true), + audit: None, + encryption: None, + }]), + warn: None, + }; + + let ep = EvaluatedPolicy::new("mortar-1", policy, mappings); + let info = PolicyInfo::from(&ep); + + assert_eq!(info.mortar_id, "mortar-1"); + assert_eq!(info.description, Some("Test".to_string())); + assert_eq!(info.deny_rules, 2); + assert_eq!(info.require_rules, 1); + assert_eq!(info.warn_rules, 0); + assert_eq!(info.services.len(), 1); + } +} diff --git a/fabricksd/src/proxy/egress.rs b/fabricksd/src/proxy/egress.rs index f32c050..0c6fb99 100644 --- a/fabricksd/src/proxy/egress.rs +++ b/fabricksd/src/proxy/egress.rs @@ -21,6 +21,7 @@ use fabricks_common::models::capability::Capabilities; use crate::error::{DaemonError, Result}; use crate::network::{ConnectionDecision, NetworkManager, validate_connection}; +use crate::policy::PolicyManager; /// Request to be executed by the egress proxy. #[derive(Debug, Clone)] @@ -206,6 +207,8 @@ pub struct EgressProxy { client: Client, /// Network manager for connection validation. network_manager: Arc, + /// Policy manager for policy evaluation. + policy_manager: Option>, /// Handler for routing to internal services. internal_route_handler: Option, } @@ -217,6 +220,18 @@ impl EgressProxy { /// /// Returns an error if the HTTP client cannot be created. pub fn new(network_manager: Arc) -> Result { + Self::with_policy_manager(network_manager, None) + } + + /// Creates a new egress proxy with an optional policy manager. + /// + /// # Errors + /// + /// Returns an error if the HTTP client cannot be created. + pub fn with_policy_manager( + network_manager: Arc, + policy_manager: Option>, + ) -> Result { let client = Client::builder() .build() .map_err(|e| DaemonError::IoError(std::io::Error::other(e.to_string())))?; @@ -224,6 +239,7 @@ impl EgressProxy { Ok(Self { client, network_manager, + policy_manager, internal_route_handler: None, }) } @@ -268,12 +284,14 @@ impl EgressProxy { ); // Validate the connection + let policy_mgr = self.policy_manager.as_ref().map(Arc::as_ref); let decision = validate_connection( from_service_id, capabilities, &host, port, &self.network_manager, + policy_mgr, ) .await; @@ -340,12 +358,14 @@ impl EgressProxy { ); // Validate the connection + let policy_mgr = self.policy_manager.as_ref().map(Arc::as_ref); let decision = validate_connection( from_service_id, capabilities, &request.host, request.port, &self.network_manager, + policy_mgr, ) .await; @@ -549,6 +569,7 @@ impl std::fmt::Debug for EgressProxy { "has_internal_handler", &self.internal_route_handler.is_some(), ) + .field("has_policy_manager", &self.policy_manager.is_some()) .finish_non_exhaustive() } } diff --git a/fabricksd/src/state.rs b/fabricksd/src/state.rs index 1ffb495..64e96ec 100644 --- a/fabricksd/src/state.rs +++ b/fabricksd/src/state.rs @@ -13,6 +13,7 @@ use crate::error::Result; use crate::events::EventBus; use crate::health::{HealthMonitor, HealthMonitorConfig}; use crate::network::{NetworkManager, ServiceRegistry}; +use crate::policy::PolicyManager; use crate::proxy::{EgressProxy, ProxyServer, RequestHandler, ServiceRouter, TcpConnectionHandler}; use crate::scaler::{AutoScaler, AutoScalerConfig, MetricsCollector, MetricsCollectorConfig}; use crate::service::ServiceManager; @@ -67,6 +68,9 @@ pub struct AppState { /// Auto-scaler for automatic scaling based on metrics. pub auto_scaler: Arc, + /// Policy manager for security policies. + pub policy_manager: Arc, + /// Shutdown signal sender. shutdown_tx: broadcast::Sender<()>, } @@ -117,8 +121,14 @@ impl AppState { Arc::clone(&network_manager), )); - // Create egress proxy for outbound requests - let egress_proxy = Arc::new(EgressProxy::new(Arc::clone(&network_manager))?); + // Create policy manager (needed by egress proxy) + let policy_manager = Arc::new(PolicyManager::new(Arc::clone(&event_bus))); + + // Create egress proxy for outbound requests with policy manager + let egress_proxy = Arc::new(EgressProxy::with_policy_manager( + Arc::clone(&network_manager), + Some(Arc::clone(&policy_manager)), + )?); // Create health monitor let health_monitor_config = HealthMonitorConfig::default(); @@ -170,6 +180,7 @@ impl AppState { volume_manager, metrics_collector, auto_scaler, + policy_manager, shutdown_tx, }) } @@ -289,6 +300,7 @@ mod tests { &cloned.metrics_collector )); assert!(Arc::ptr_eq(&state.auto_scaler, &cloned.auto_scaler)); + assert!(Arc::ptr_eq(&state.policy_manager, &cloned.policy_manager)); } #[tokio::test]