diff --git a/net-guardia/src/adapter/http/fusion.rs b/net-guardia/src/adapter/http/fusion.rs index 493285e..1fe78d3 100644 --- a/net-guardia/src/adapter/http/fusion.rs +++ b/net-guardia/src/adapter/http/fusion.rs @@ -10,7 +10,7 @@ use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web}; use crate::core::detection::metrics::FusionMetrics; -use crate::interface::port::audit::AuditRepo; +use crate::interface::port::audit::{AuditLogEntry, AuditRepo}; /// Maximum audit rows scanned per explain request. Caps DB work in /// case the audit chain grows large enough that a naive full-table @@ -80,12 +80,8 @@ async fn explain_ip(req: HttpRequest, audit: web::Data) -> impl R /// /// Extracted as a free function so tests can cover the filter / /// ordering / cap behaviour without an in-memory DB. -pub fn filter_fusion_evidence_for_ip( - entries: &[crate::interface::port::audit::AuditLogEntry], - target_ip: &str, - cap: usize, -) -> Vec { - let mut filtered: Vec<&crate::interface::port::audit::AuditLogEntry> = entries +pub fn filter_fusion_evidence_for_ip(entries: &[AuditLogEntry], target_ip: &str, cap: usize) -> Vec { + let mut filtered: Vec<&AuditLogEntry> = entries .iter() .filter(|entry| detail_matches_src_ip(&entry.detail, target_ip)) .collect(); @@ -117,7 +113,6 @@ fn detail_matches_src_ip(detail_json: &str, target_ip: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::interface::port::audit::AuditLogEntry; fn entry(id: i64, src_ip: &str, attack: &str) -> AuditLogEntry { let detail = serde_json::json!({ diff --git a/net-guardia/src/adapter/persistence/repository.rs b/net-guardia/src/adapter/persistence/repository.rs index ec7a1cb..839a5b7 100644 --- a/net-guardia/src/adapter/persistence/repository.rs +++ b/net-guardia/src/adapter/persistence/repository.rs @@ -122,6 +122,9 @@ impl Database { use std::fmt::Write; type HmacSha256 = Hmac; + // SAFETY: HMAC-SHA256 accepts keys of any length; the only error + // `new_from_slice` returns (`InvalidLength`) is unreachable for this + // algorithm. The unreachable!() is the correct sentinel. let mut mac = HmacSha256::new_from_slice(&self.api_key_hmac).unwrap_or_else(|_| unreachable!()); mac.update(raw_key.as_bytes()); let result = mac.finalize().into_bytes(); @@ -1980,7 +1983,7 @@ impl SoarRepo for Database { self.list_soar_executions(limit) } - // --- tx-4 / tx-5: intra-aggregate atomic operations --- + // --- intra-aggregate atomic operations --- fn insert_playbook_atomic( &self, @@ -2116,7 +2119,7 @@ impl ApiKeyRepo for Database { } impl DbAdminRepo for Database { - /// tx-1 — Commit a SOAR-driven block to both `soar_block_rules` and + /// Commit a SOAR-driven block to both `soar_block_rules` and /// `acl_rules` in one transaction. Callers must have already installed /// the eBPF block before calling this, and are responsible for removing /// the eBPF block if this returns Err. @@ -2142,8 +2145,8 @@ impl DbAdminRepo for Database { Ok(soar_block_id) } - /// tx-2 / tx-3 — Clear a SOAR-driven block: remove the `acl_rules` entry - /// and mark the `soar_block_rules` row as unblocked in one transaction. + /// Clear a SOAR-driven block: remove the `acl_rules` entry and mark + /// the `soar_block_rules` row as unblocked in one transaction. /// Callers handle eBPF unblock separately. fn commit_soar_unblock_to_db(&self, soar_block_id: i64, ip_version: u8, source_ip: &str) -> Result<(), Error> { let mut conn = self.conn()?; @@ -2333,7 +2336,7 @@ mod tests { assert_eq!(identity.user_count().unwrap(), 1); } - /// tx-1 — happy path. Verifies `commit_soar_block_to_db` writes both + /// Happy path. Verifies `commit_soar_block_to_db` writes both /// `soar_block_rules` and `acl_rules` atomically. #[test] fn test_commit_soar_block_happy_path() { @@ -2360,8 +2363,8 @@ mod tests { assert_eq!(rules[0].3, "10.0.0.99"); } - /// tx-2 / tx-3 — verifies `commit_soar_unblock_to_db` removes the ACL row - /// and marks the SOAR row as unblocked in one transaction. + /// Verifies `commit_soar_unblock_to_db` removes the ACL row and marks + /// the SOAR row as unblocked in one transaction. #[test] fn test_commit_soar_unblock_clears_both_tables() { let db = test_db(); @@ -2380,7 +2383,7 @@ mod tests { assert!(db.get_active_soar_blocks().unwrap().is_empty()); } - /// tx-4 — `insert_playbook_atomic` writes playbook + actions + conditions + /// Verifies `insert_playbook_atomic` writes playbook + actions + conditions /// atomically. #[test] fn test_insert_playbook_atomic_writes_all_three_tables() { diff --git a/net-guardia/src/core/playbook_service.rs b/net-guardia/src/core/playbook_service.rs index 036f511..b666c6a 100644 --- a/net-guardia/src/core/playbook_service.rs +++ b/net-guardia/src/core/playbook_service.rs @@ -121,7 +121,7 @@ impl PlaybookService { } pub fn create_playbook(&self, input: &CreatePlaybookInput) -> Result { - // tx-4: single atomic insert (playbook + actions + conditions) + // Single atomic insert (playbook + actions + conditions). let actions: Vec<(i64, String, String)> = input .actions .iter() @@ -163,7 +163,7 @@ impl PlaybookService { condition_window_secs: input.condition_window_secs, cooldown_secs: input.cooldown_secs, }; - // tx-5: single atomic update (playbook metadata + replace actions/conditions) + // Single atomic update (playbook metadata + replace actions/conditions). let actions: Vec<(i64, String, String)> = input .actions .iter() @@ -233,8 +233,8 @@ impl PlaybookService { // Remove from eBPF ACL self.access_control.unblock_ip(source_ip)?; - // Atomically drop acl_rules entry AND mark soar_block_rules unblocked - // in one transaction (R2 mitigation, tx-3 per M2_CARVE_PLAN §4b). + // Atomically drop acl_rules entry AND mark soar_block_rules + // unblocked in one transaction. let ip_version = ip_version_from_str(source_ip); self.db.commit_soar_unblock_to_db(id, ip_version, source_ip)?; diff --git a/net-guardia/src/core/soar/actions.rs b/net-guardia/src/core/soar/actions.rs index 6014f10..1ae32c4 100644 --- a/net-guardia/src/core/soar/actions.rs +++ b/net-guardia/src/core/soar/actions.rs @@ -6,6 +6,7 @@ //! layer (`matcher.rs`) decided the playbook should fire. use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; @@ -16,8 +17,10 @@ use tokio::net::lookup_host; use tokio::task::spawn_blocking; use url::Url; +use crate::core::email::scheduler::SmtpClient; use crate::core::playbook_service::ip_version_from_str; use crate::core::soar::engine::SoarEngine; +use crate::interface::port::app_repo::AppRepo; use crate::interface::port::notification::AlertPayload; use crate::model::error::Error; use crate::model::error::soar::SoarError; @@ -25,6 +28,37 @@ use crate::model::event::ThreatDetectedEvent; use crate::model::log::soar::SoarLog; use crate::model::soar::playbook::{Playbook, PlaybookAction}; +/// Default block TTL when a `block_ip` action omits `ttl_secs`. +const DEFAULT_BLOCK_TTL_SECS: u64 = 1800; +/// DB-overridable cap on per-block TTL — see setting `soar_max_ttl_secs`. +const DEFAULT_SOAR_MAX_TTL_SECS: u64 = 86_400; +/// DB-overridable cap on concurrent SOAR-driven blocks — see setting +/// `soar_max_auto_block_cap`. +const DEFAULT_SOAR_MAX_AUTO_BLOCK_CAP: u32 = 100; +/// Default rate-limit reduction factor when an `adjust_rate_limit` action +/// omits `factor`. 0.5 = halve the current rate. +const DEFAULT_RATE_LIMIT_FACTOR: f64 = 0.5; +/// Default rate-limit TTL when an `adjust_rate_limit` action omits `ttl_secs`. +const DEFAULT_RATE_LIMIT_TTL_SECS: u64 = 600; +/// Lower bound on the rate-limit factor — anything below 1% of current +/// would brick traffic flow. +const RATE_LIMIT_FACTOR_MIN: f64 = 0.01; +/// Upper bound on the rate-limit factor — `1.0` is a no-op; values above +/// would *raise* the limit, which isn't a SOAR mitigation. +const RATE_LIMIT_FACTOR_MAX: f64 = 1.0; +/// Default webhook timeout when an action omits `timeout_secs`. +const DEFAULT_WEBHOOK_TIMEOUT_SECS: u64 = 10; +/// Fallback port when the webhook URL has no explicit port and no +/// well-known scheme port. +const DEFAULT_WEBHOOK_HTTPS_PORT: u16 = 443; +/// Sentinel `playbook_id` used by the no-matching-playbook fallback path. +/// `-1` is reserved on the audit / cooldown maps and never assigned to a +/// real DB playbook row. +const FALLBACK_PLAYBOOK_ID: i64 = -1; +/// Cooldown applied to the fallback path so a single noisy IP doesn't +/// spam the WORM audit chain on every fused detection. +const FALLBACK_COOLDOWN_SECS: i64 = 300; + impl SoarEngine { /// Check if the system is in enforce mode (as opposed to monitor mode). /// Reads from the in-memory AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2. @@ -128,7 +162,11 @@ impl SoarEngine { event: &ThreatDetectedEvent, playbook_id: i64, ) -> Result { - let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(1800); + let ttl_secs = action + .params + .get("ttl_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BLOCK_TTL_SECS); // Two settings read together off the tokio worker thread — r2d2's // pool.get() and rusqlite are blocking, so back-to-back calls inside @@ -208,10 +246,18 @@ impl SoarEngine { ) -> Result { let owner = self.rate_limit.as_ref().ok_or(SoarError::RateLimitUnavailable)?; - let factor = action.params.get("factor").and_then(|v| v.as_f64()).unwrap_or(0.5); - let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(600); + let factor = action + .params + .get("factor") + .and_then(|v| v.as_f64()) + .unwrap_or(DEFAULT_RATE_LIMIT_FACTOR); + let ttl_secs = action + .params + .get("ttl_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_RATE_LIMIT_TTL_SECS); - if !(0.01..=1.0).contains(&factor) { + if !(RATE_LIMIT_FACTOR_MIN..=RATE_LIMIT_FACTOR_MAX).contains(&factor) { Err(SoarError::InvalidRateLimitFactor(factor))?; } @@ -263,7 +309,6 @@ impl SoarEngine { /// Send email alert. async fn action_send_email(&self, event: &ThreatDetectedEvent) -> Result { - use crate::core::email::scheduler::SmtpClient; match SmtpClient::from_soar_port(&*self.db, self.secrets.as_deref())? { Some(smtp) => { let subject = format!( @@ -303,7 +348,11 @@ impl SoarEngine { .and_then(|v| v.as_str()) .ok_or_else(|| SoarError::WebhookMissingParam("url"))?; - let timeout_secs = action.params.get("timeout_secs").and_then(|v| v.as_u64()).unwrap_or(10); + let timeout_secs = action + .params + .get("timeout_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_WEBHOOK_TIMEOUT_SECS); // Parse URL and extract host let parsed_url = Url::parse(url_str).map_err(|e| SoarError::ActionFailed("webhook", e))?; @@ -311,7 +360,7 @@ impl SoarEngine { let host = parsed_url.host_str().ok_or(SoarError::WebhookUrlNoHost)?; // DNS resolve all IPs and verify none are private/loopback/link-local - let port = parsed_url.port_or_known_default().unwrap_or(443); + let port = parsed_url.port_or_known_default().unwrap_or(DEFAULT_WEBHOOK_HTTPS_PORT); let resolve_target = format!("{}:{}", host, port); let addrs: Vec = lookup_host(&resolve_target) .await @@ -404,31 +453,31 @@ impl SoarEngine { return Ok(()); } - // Check cooldown — use playbook_id=-1 for fallback actions - if self.is_cooldown_active(-1, &event.source_ip, 300) { + // Check cooldown — uses FALLBACK_PLAYBOOK_ID as the synthetic key + if self.is_cooldown_active(FALLBACK_PLAYBOOK_ID, &event.source_ip, FALLBACK_COOLDOWN_SECS) { log!(SoarLog::CooldownActive("fallback".to_string(), event.source_ip.clone())); return Ok(()); } - // Default fallback: block IP for 30 minutes + log + // Default fallback: block IP for the default block TTL + log let fake_action = PlaybookAction { action_order: 1, action_type: "block_ip".to_string(), - params: serde_json::json!({"ttl_secs": 1800}), + params: serde_json::json!({"ttl_secs": DEFAULT_BLOCK_TTL_SECS}), }; - let block_result = self.execute_action(&fake_action, event, -1).await; + let block_result = self.execute_action(&fake_action, event, FALLBACK_PLAYBOOK_ID).await; let result_json = match &block_result { Ok(msg) => serde_json::json!({"action": "block_ip", "status": "ok", "message": msg}), Err(e) => serde_json::json!({"action": "block_ip", "status": "error", "message": e.to_string()}), }; // Record cooldown for fallback - self.record_cooldown(-1, &event.source_ip); + self.record_cooldown(FALLBACK_PLAYBOOK_ID, &event.source_ip); - // Audit trail with playbook_id = -1 + // Audit trail under the fallback synthetic playbook id self.db.insert_soar_execution( - -1, + FALLBACK_PLAYBOOK_ID, Some(&event.source_ip), &event.attack_type, &serde_json::to_string(&[result_json]).unwrap_or_default(), @@ -439,10 +488,6 @@ impl SoarEngine { } } -use std::sync::Arc; - -use crate::interface::port::app_repo::AppRepo; - /// Read both block-related caps in a single offloaded blocking call so the /// async caller pays one spawn_blocking hop instead of two. async fn read_block_caps(db: Arc) -> Result<(u64, u32), Error> { @@ -452,13 +497,13 @@ async fn read_block_caps(db: Arc) -> Result<(u64, u32), Error> { .ok() .flatten() .and_then(|v| v.parse().ok()) - .unwrap_or(86400); + .unwrap_or(DEFAULT_SOAR_MAX_TTL_SECS); let max_cap: u32 = db .get_setting("soar_max_auto_block_cap") .ok() .flatten() .and_then(|v| v.parse().ok()) - .unwrap_or(100); + .unwrap_or(DEFAULT_SOAR_MAX_AUTO_BLOCK_CAP); Ok::<_, Error>((max_ttl, max_cap)) }) .await @@ -471,10 +516,10 @@ async fn read_max_ttl(db: Arc) -> u64 { .ok() .flatten() .and_then(|v| v.parse().ok()) - .unwrap_or(86400) + .unwrap_or(DEFAULT_SOAR_MAX_TTL_SECS) }) .await - .unwrap_or(86400) + .unwrap_or(DEFAULT_SOAR_MAX_TTL_SECS) } async fn commit_block_blocking( diff --git a/net-guardia/src/core/soar/scheduler.rs b/net-guardia/src/core/soar/scheduler.rs index 6d091e3..cc23db8 100644 --- a/net-guardia/src/core/soar/scheduler.rs +++ b/net-guardia/src/core/soar/scheduler.rs @@ -89,8 +89,8 @@ impl TtlScheduler { )); } - // Atomically drop acl_rules entry AND mark soar_block_rules unblocked - // in one transaction (R2 mitigation, tx-2 per M2_CARVE_PLAN §4b). + // Atomically drop acl_rules entry AND mark soar_block_rules + // unblocked in one transaction. let ip_version = ip_version_from_str(source_ip); self.db.commit_soar_unblock_to_db(*id, ip_version, source_ip)?; self.soar_engine.decrement_block_count(); diff --git a/net-guardia/src/infrastructure/service_factory.rs b/net-guardia/src/infrastructure/service_factory.rs index 1ae3e01..f2083c4 100644 --- a/net-guardia/src/infrastructure/service_factory.rs +++ b/net-guardia/src/infrastructure/service_factory.rs @@ -41,8 +41,12 @@ use crate::infrastructure::suricata_manager::SuricataManager; use crate::interface::communication::command_types::ChangeEnforceModeCommand; use crate::interface::communication::query_types::GetEnforceModeQuery; use crate::interface::port::access_control::AccessControlPort; +use crate::interface::port::access_control_admin::AccessControlAdminPort; use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::dns_filter_api::DnsFilterPort; +use crate::interface::port::geo_block_api::GeoBlockPort; use crate::interface::port::notification::AlertNotifier; +use crate::interface::port::rate_limit_api::RateLimitPort; use crate::interface::port::secret_store::SecretStorePort; use crate::interface::port::setting::SettingRepo; use crate::interface::port::soar::SoarRepo; @@ -259,8 +263,7 @@ impl ServiceFactory { Arc::new(EbpfAccessControlAdapter::new(ebpf_services.access_control.clone())); // Create SOAR engine - let rate_limit_port: Arc = - ebpf_services.rate_limit.clone(); + let rate_limit_port: Arc = ebpf_services.rate_limit.clone(); let soar_engine = Arc::new(SoarEngine::new( db.clone(), access_control_port.clone(), @@ -280,12 +283,9 @@ impl ServiceFactory { // Create domain services (Phase 2B) — upcast concrete eBPF services to // their port-layer traits so the core services see only abstract ports. - let access_control_admin: Arc = - ebpf_services.access_control.clone(); - let geo_block_port: Arc = - ebpf_services.geo_block.clone(); - let dns_filter_port: Arc = - ebpf_services.dns_filter.clone(); + let access_control_admin: Arc = ebpf_services.access_control.clone(); + let geo_block_port: Arc = ebpf_services.geo_block.clone(); + let dns_filter_port: Arc = ebpf_services.dns_filter.clone(); let acl_service = Arc::new(AclService::new( db.clone() as Arc, access_control_admin, diff --git a/net-guardia/src/infrastructure/suricata_monitor.rs b/net-guardia/src/infrastructure/suricata_monitor.rs index 63d7b84..45a7e01 100644 --- a/net-guardia/src/infrastructure/suricata_monitor.rs +++ b/net-guardia/src/infrastructure/suricata_monitor.rs @@ -37,6 +37,23 @@ const POLL_INTERVAL: Duration = Duration::from_millis(200); /// How long to wait between checks while the file does not yet exist. const FILE_WAIT_INTERVAL: Duration = Duration::from_secs(1); +/// IANA protocol numbers for Suricata's `proto` strings. +const IANA_PROTO_ICMP: u8 = 1; +const IANA_PROTO_TCP: u8 = 6; +const IANA_PROTO_UDP: u8 = 17; + +/// Suricata severity → SOAR confidence mapping. Higher severity ⇒ higher +/// confidence so SOAR thresholds tend to trip on real alerts. +const SURICATA_CONFIDENCE_HIGH: f32 = 0.95; +const SURICATA_CONFIDENCE_MEDIUM: f32 = 0.80; +const SURICATA_CONFIDENCE_LOW: f32 = 0.65; +const SURICATA_CONFIDENCE_INFO: f32 = 0.50; + +/// Suricata severity numeric encoding (eve.json `alert.severity`). +const SURICATA_SEVERITY_HIGH: u64 = 1; +const SURICATA_SEVERITY_MEDIUM: u64 = 2; +const SURICATA_SEVERITY_LOW: u64 = 3; + pub struct SuricataMonitor { config: Arc, detection_tx: mpsc::Sender, @@ -145,9 +162,9 @@ impl SuricataMonitor { let dest_ip = v.get("dest_ip")?.as_str()?.to_string(); let proto_str = v.get("proto").and_then(|x| x.as_str()).unwrap_or(""); let protocol: u8 = match proto_str { - "TCP" => 6, - "UDP" => 17, - "ICMP" => 1, + "TCP" => IANA_PROTO_TCP, + "UDP" => IANA_PROTO_UDP, + "ICMP" => IANA_PROTO_ICMP, _ => 0, }; @@ -161,12 +178,15 @@ impl SuricataMonitor { // Suricata severity: 1=high, 2=medium, 3=low, 4=informational. // Map to confidence in [0.5, 1.0] so high-severity alerts tend to trip // SOAR condition thresholds. - let severity = alert.get("severity").and_then(|x| x.as_u64()).unwrap_or(3); + let severity = alert + .get("severity") + .and_then(|x| x.as_u64()) + .unwrap_or(SURICATA_SEVERITY_LOW); let confidence = match severity { - 1 => 0.95, - 2 => 0.80, - 3 => 0.65, - _ => 0.50, + SURICATA_SEVERITY_HIGH => SURICATA_CONFIDENCE_HIGH, + SURICATA_SEVERITY_MEDIUM => SURICATA_CONFIDENCE_MEDIUM, + SURICATA_SEVERITY_LOW => SURICATA_CONFIDENCE_LOW, + _ => SURICATA_CONFIDENCE_INFO, }; log!(SuricataLog::AlertForwarded( diff --git a/net-guardia/src/infrastructure/system.rs b/net-guardia/src/infrastructure/system.rs index 62254d5..4958dcf 100644 --- a/net-guardia/src/infrastructure/system.rs +++ b/net-guardia/src/infrastructure/system.rs @@ -44,6 +44,7 @@ use crate::infrastructure::service_factory::ServiceFactory; use crate::infrastructure::suricata_manager::SuricataManager; use crate::infrastructure::suricata_monitor::SuricataMonitor; use crate::interface::port::audit::AuditRepo; +use crate::interface::port::packet_sink::PacketSinkFactory; use crate::interface::port::setting::SettingRepo; use crate::interface::port::stats::StatsRepo; use crate::model::config::constants::{MODELS_DIR, STAGING_SUBDIR}; @@ -211,8 +212,7 @@ impl System { // When maps exist but bind fails (e.g. igb on kernel < 6.17), record // the classified reason and continue — the ML engine will see no // packets, same as a network that is simply quiet. - let sink_factory: Arc = - app_services.ml_engine.clone(); + let sink_factory: Arc = app_services.ml_engine.clone(); if let Err(e) = ebpf_services.run(sink_factory).await { use crate::infrastructure::ebpf_preflight; use crate::model::system::health::EbpfFailStage; diff --git a/net-guardia/src/interface/port/db_admin.rs b/net-guardia/src/interface/port/db_admin.rs index 08bc738..4ecfc2e 100644 --- a/net-guardia/src/interface/port/db_admin.rs +++ b/net-guardia/src/interface/port/db_admin.rs @@ -8,19 +8,8 @@ use crate::model::error::Error; /// primitive (which cannot be used through a trait object because Rust /// forbids generic methods on dyn traits), each cross-aggregate use case /// gets a dedicated business method. -/// -/// The five tx points identified during M2 design (see -/// `docs/strategy/debate/v12-architecture-review/M2_CARVE_PLAN.md` §4b): -/// -/// | # | Use case | Method | -/// |---|---|---| -/// | tx-1 | SOAR block commit (soar_block_rules + acl_rules) | `commit_soar_block_to_db` | -/// | tx-2 | TTL unblock (acl_rules delete + soar row mark) | `commit_soar_unblock_to_db` | -/// | tx-3 | Manual unblock (same as tx-2) | `commit_soar_unblock_to_db` | -/// | tx-4 | Playbook create + conditions + actions | `insert_playbook_atomic` on SoarRepo | -/// | tx-5 | Playbook update + replace conditions/actions | `update_playbook_atomic` on SoarRepo | pub trait DbAdminRepo: Send + Sync { - /// tx-1 — Atomically record a SOAR-driven IP block to both + /// Atomically record a SOAR-driven IP block to both /// `soar_block_rules` and `acl_rules`. Returns the new /// `soar_block_rules.id`. Callers are responsible for eBPF rollback if /// this fails. @@ -32,9 +21,9 @@ pub trait DbAdminRepo: Send + Sync { expires_at: &str, ) -> Result; - /// tx-2 / tx-3 — Atomically clear a SOAR-driven IP block: removes the - /// corresponding `acl_rules` row (if present) and marks the - /// `soar_block_rules` row as unblocked. Callers handle eBPF unblock - /// separately. + /// Atomically clear a SOAR-driven IP block: removes the corresponding + /// `acl_rules` row (if present) and marks the `soar_block_rules` row as + /// unblocked. Callers handle eBPF unblock separately. Used by both the + /// TTL-unblock and manual-unblock paths. fn commit_soar_unblock_to_db(&self, soar_block_id: i64, ip_version: u8, source_ip: &str) -> Result<(), Error>; } diff --git a/net-guardia/src/interface/port/soar.rs b/net-guardia/src/interface/port/soar.rs index 63265ff..db25f94 100644 --- a/net-guardia/src/interface/port/soar.rs +++ b/net-guardia/src/interface/port/soar.rs @@ -95,9 +95,9 @@ pub trait SoarRepo: Send + Sync { ) -> Result; fn list_soar_executions(&self, limit: i64) -> Result, Error>; - // --- Intra-aggregate atomic operations (tx-4 / tx-5 per M2_CARVE_PLAN §4b) --- + // --- Intra-aggregate atomic operations --- - /// tx-4 — Atomically create a playbook with its conditions and actions. + /// Atomically create a playbook with its conditions and actions. /// All rows (playbook + conditions + actions) commit together; any error /// rolls back the whole insert. Returns the new playbook id. /// @@ -116,9 +116,9 @@ pub trait SoarRepo: Send + Sync { conditions: &[(String, String, String, Option)], ) -> Result; - /// tx-5 — Atomically update a playbook's metadata and replace its - /// conditions and actions. Returns `Ok(false)` if no playbook with that - /// id exists; otherwise `Ok(true)` after the whole update commits. + /// Atomically update a playbook's metadata and replace its conditions + /// and actions. Returns `Ok(false)` if no playbook with that id exists; + /// otherwise `Ok(true)` after the whole update commits. fn update_playbook_atomic( &self, id: i64,