Skip to main content

rust_supervisor/ipc/security/
mod.rs

1//! IPC security pipeline.
2//!
3//! Orchestrates the nine control points (C1-C9) in the contract-defined
4//! execution order. The pipeline is loaded once from `IpcSecurityConfig`
5//! plus the root audit configuration, then invoked per-request as a
6//! pre-dispatch filter by the dashboard IPC service. C7 (audit) runs
7//! post-dispatch.
8
9pub mod allowlist;
10pub mod audit;
11pub mod authz;
12pub mod idempotency;
13pub mod limits;
14pub mod peer_identity;
15pub mod replay;
16
17use crate::config::audit::AuditConfig;
18use crate::config::ipc_security::IpcSecurityConfig;
19use crate::dashboard::error::DashboardError;
20use std::collections::HashMap;
21
22use self::audit::AuditRecord;
23use self::idempotency::IdempotencyCache;
24use self::limits::TokenBucket;
25use self::replay::ReplayWindow;
26
27/// Assembled IPC security pipeline holding all control point instances.
28pub struct IpcSecurityPipeline {
29    /// Stored configuration for inspection.
30    #[allow(dead_code)]
31    config: IpcSecurityConfig,
32    /// Root audit configuration used by C7.
33    audit_config: AuditConfig,
34    /// C4: replay protection sliding window.
35    replay_window: ReplayWindow,
36    /// C6: per-connection token buckets, keyed by connection identifier.
37    rate_limiters: HashMap<String, TokenBucket>,
38    /// C7: audit persistence backend.
39    audit: audit::AuditBackend,
40    /// C8: command idempotency cache.
41    idempotency_cache: IdempotencyCache,
42}
43
44/// Outcome of pre-dispatch security checks.
45pub enum CheckOutcome {
46    /// All checks passed; proceed to dispatch.
47    Passed,
48    /// A control point denied the request; return this error.
49    Denied(DashboardError),
50}
51
52impl IpcSecurityPipeline {
53    /// Creates a new pipeline from configuration.
54    ///
55    /// # Arguments
56    ///
57    /// - `config`: IPC security configuration.
58    /// - `audit_config`: Root audit persistence configuration.
59    ///
60    /// # Returns
61    ///
62    /// Returns an initialized [`IpcSecurityPipeline`] with all control
63    /// points ready.
64    pub fn new(config: IpcSecurityConfig, audit_config: AuditConfig) -> Self {
65        Self {
66            replay_window: ReplayWindow::from_config(&config.replay_protection),
67            rate_limiters: HashMap::new(),
68            audit: audit::AuditBackend::from_config(&audit_config),
69            idempotency_cache: IdempotencyCache::from_config(&config.idempotency),
70            config,
71            audit_config,
72        }
73    }
74
75    /// Runs pre-dispatch security checks.
76    ///
77    /// Execution order (per contract):
78    /// C6 → C5 → C2 → C4 → C3
79    ///
80    /// C1 (socket owner) runs at bind time and is not in the per-request
81    /// pipeline. C9 (allowlist) runs at extension points.
82    ///
83    /// # Arguments
84    ///
85    /// - `method`: IPC method name.
86    /// - `request_id`: Request identifier (for C4 replay check and C8 cache).
87    /// - `raw_body_len`: Byte length of the raw request body (for C5).
88    /// - `peer_identity`: Extracted peer identity snapshot (for C2/C3).
89    /// - `connection_id`: Opaque connection identifier (for per-connection C6).
90    ///
91    /// # Returns
92    ///
93    /// Returns `CheckOutcome::Passed` when all checks pass, or
94    /// `CheckOutcome::Denied(error)` with the denial error. The caller
95    /// must write audit records and execute the actual dispatch.
96    pub fn check(
97        &mut self,
98        method: &str,
99        request_id: &str,
100        raw_body_len: usize,
101        peer_identity: &peer_identity::PeerIdentity,
102        connection_id: &str,
103    ) -> CheckOutcome {
104        // C6: Rate limit
105        let rate_limiter = self
106            .rate_limiters
107            .entry(connection_id.to_string())
108            .or_insert_with(|| TokenBucket::from_config(&self.config.rate_limit));
109        if let Err(err) = rate_limiter.check_rate_limit(&self.config.rate_limit) {
110            tracing::warn!(
111                target: "rust_supervisor::ipc::security::rate_limit",
112                %connection_id,
113                "rate limit exceeded"
114            );
115            return CheckOutcome::Denied(err);
116        }
117
118        // C5: Size limit
119        if let Err(err) = limits::check_request_size(raw_body_len, &self.config.request_size_limit)
120        {
121            tracing::warn!(
122                target: "rust_supervisor::ipc::security::size_limit",
123                actual = raw_body_len,
124                limit = self.config.request_size_limit.max_bytes,
125                "request too large"
126            );
127            return CheckOutcome::Denied(err);
128        }
129
130        // C2: Peer credentials
131        if let Err(err) =
132            peer_identity::verify_peer_identity(peer_identity, &self.config.peer_identity)
133        {
134            tracing::warn!(
135                target: "rust_supervisor::ipc::security::peer_credentials",
136                peer_uid = peer_identity.uid,
137                "peer credential check failed"
138            );
139            return CheckOutcome::Denied(err);
140        }
141
142        // C4: Replay protection
143        if self.config.replay_protection.enabled
144            && let Err(err) = self.replay_window.check_and_record(request_id)
145        {
146            tracing::warn!(
147                target: "rust_supervisor::ipc::security::replay",
148                %request_id,
149                "replay detected"
150            );
151            return CheckOutcome::Denied(err);
152        }
153
154        // C3: Command authorization
155        if let Err(err) =
156            authz::verify_authorization(method, peer_identity.uid, &self.config.authorization)
157        {
158            tracing::warn!(
159                target: "rust_supervisor::ipc::security::authorization",
160                %method,
161                peer_uid = peer_identity.uid,
162                "command not authorized"
163            );
164            return CheckOutcome::Denied(err);
165        }
166
167        // C8: Idempotency — check cache before letting dispatch happen
168        // The caller checks cache hit via `check_idempotency`.
169        CheckOutcome::Passed
170    }
171
172    /// Checks the idempotency cache for a cached response (C8).
173    ///
174    /// Called after `check()` passes but before dispatch.
175    ///
176    /// # Arguments
177    ///
178    /// - `request_id`: Request identifier.
179    ///
180    /// # Returns
181    ///
182    /// Returns `Some(cached_result_json)` if a cached result exists,
183    /// or `None` if no cache hit.
184    pub fn check_idempotency(&self, request_id: &str) -> Option<String> {
185        if self.config.idempotency.enabled {
186            self.idempotency_cache.get(request_id)
187        } else {
188            None
189        }
190    }
191
192    /// Caches a dispatch result for idempotency (C8).
193    ///
194    /// # Arguments
195    ///
196    /// - `request_id`: Request identifier.
197    /// - `response_json`: Serialized response to cache.
198    pub fn cache_result(&mut self, request_id: &str, response_json: &str) {
199        if self.config.idempotency.enabled {
200            self.idempotency_cache
201                .put(request_id.to_string(), response_json.to_string());
202        }
203    }
204
205    /// Writes an audit record after dispatch (C7).
206    ///
207    /// Returns `Ok(())` on success or `Err(DashboardError)` when the audit
208    /// backend is unwritable. The caller should fail closed for high-risk
209    /// commands.
210    ///
211    /// # Arguments
212    ///
213    /// - `method`: IPC method name.
214    /// - `peer_identity`: Peer identity snapshot.
215    /// - `allowed`: Whether the request was allowed.
216    /// - `denial_error`: The denial error if denied.
217    /// - `denial_control_point`: Which control point denied (C1-C9 or "dispatch").
218    ///
219    /// # Returns
220    ///
221    /// Returns `Ok(())` when the audit record was written, or
222    /// `Err(DashboardError)` when the backend is unwritable.
223    pub fn write_audit(
224        &mut self,
225        method: &str,
226        peer_identity: &peer_identity::PeerIdentity,
227        allowed: bool,
228        denial_error: Option<&DashboardError>,
229        denial_control_point: &str,
230    ) -> Result<(), DashboardError> {
231        if !self.audit_config.enabled {
232            return Ok(());
233        }
234        let hash = format!("uid:{}:pid:{}", peer_identity.uid, peer_identity.pid);
235        let now = std::time::SystemTime::now()
236            .duration_since(std::time::UNIX_EPOCH)
237            .unwrap_or_default()
238            .as_secs()
239            .to_string();
240        let record = AuditRecord {
241            timestamp: now,
242            method: method.to_string(),
243            initiator_hash: hash,
244            correlation_id: None,
245            allowed,
246            denial_code: denial_error.map(|e| e.code.clone()),
247            denial_control_point: if allowed {
248                None
249            } else {
250                Some(denial_control_point.to_string())
251            },
252        };
253        self.audit.write(&record).map_err(|err| {
254            let count = audit::alerts::increment_failure_count();
255            tracing::error!(
256                target: "rust_supervisor::ipc::security::audit",
257                failure_count = count,
258                ?err,
259                "audit write failed"
260            );
261            err
262        })
263    }
264}