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
22pub use self::audit::AuditFailureStrategy;
23use self::audit::AuditRecord;
24use self::idempotency::IdempotencyCache;
25use self::limits::TokenBucket;
26use self::replay::ReplayWindow;
27
28/// Assembled IPC security pipeline holding all control point instances.
29pub struct IpcSecurityPipeline {
30    /// Stored configuration for inspection.
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, excluding C4 (replay) and C8
76    /// (idempotency) which are handled separately to resolve ordering.
77    ///
78    /// Execution order (per contract, with corrected C4/C8 ordering):
79    /// C6 → C5 → C2 → C3
80    ///
81    /// C4 (replay protection) and C8 (idempotency) are ordered by the
82    /// caller: C8 is checked first; if the response is already cached,
83    /// it is returned without recording in C4. Otherwise C4 records the
84    /// request_id and dispatch proceeds — see `check_replay_and_record`.
85    ///
86    /// C1 (socket owner) runs at bind time. C9 (allowlist) runs at
87    /// extension points. C7 (audit) runs post-dispatch.
88    ///
89    /// # Arguments
90    ///
91    /// - `method`: IPC method name.
92    /// - `raw_body_len`: Byte length of the raw request body (for C5).
93    /// - `peer_identity`: Extracted peer identity snapshot (for C2/C3).
94    /// - `connection_id`: Opaque connection identifier (for per-connection C6).
95    ///
96    /// # Returns
97    ///
98    /// Returns `CheckOutcome::Passed` when all checks pass, or
99    /// `CheckOutcome::Denied(error)` with the denial error.
100    pub fn check(
101        &mut self,
102        method: &str,
103        raw_body_len: usize,
104        peer_identity: &peer_identity::PeerIdentity,
105        connection_id: &str,
106    ) -> CheckOutcome {
107        // C6: Rate limit
108        let rate_limiter = self
109            .rate_limiters
110            .entry(connection_id.to_string())
111            .or_insert_with(|| TokenBucket::from_config(&self.config.rate_limit));
112        if let Err(err) = rate_limiter.check_rate_limit(&self.config.rate_limit) {
113            tracing::warn!(
114                target: "rust_supervisor::ipc::security::rate_limit",
115                %connection_id,
116                "rate limit exceeded"
117            );
118            return CheckOutcome::Denied(err);
119        }
120
121        // C5: Size limit
122        if let Err(err) = limits::check_request_size(raw_body_len, &self.config.request_size_limit)
123        {
124            tracing::warn!(
125                target: "rust_supervisor::ipc::security::size_limit",
126                actual = raw_body_len,
127                limit = self.config.request_size_limit.max_bytes,
128                "request too large"
129            );
130            return CheckOutcome::Denied(err);
131        }
132
133        // C2: Peer credentials
134        if let Err(err) =
135            peer_identity::verify_peer_identity(peer_identity, &self.config.peer_identity)
136        {
137            tracing::warn!(
138                target: "rust_supervisor::ipc::security::peer_credentials",
139                peer_uid = peer_identity.uid,
140                "peer credential check failed"
141            );
142            return CheckOutcome::Denied(err);
143        }
144
145        // C3: Command authorization
146        if let Err(err) =
147            authz::verify_authorization(method, peer_identity.uid, &self.config.authorization)
148        {
149            tracing::warn!(
150                target: "rust_supervisor::ipc::security::authorization",
151                %method,
152                peer_uid = peer_identity.uid,
153                "command not authorized"
154            );
155            return CheckOutcome::Denied(err);
156        }
157
158        CheckOutcome::Passed
159    }
160
161    /// Checks C4 replay protection and records the request_id.
162    ///
163    /// Must be called **after** C8 idempotency check: if the response is
164    /// already cached, the caller returns early without recording in C4.
165    /// This prevents C4 from rejecting a legitimate retry that hits the
166    /// idempotency cache.
167    ///
168    /// # Arguments
169    ///
170    /// - `request_id`: Request identifier to check and record.
171    ///
172    /// # Returns
173    ///
174    /// Returns `Ok(())` for a first submission, or
175    /// `Err(DashboardError)` with code `replay_detected` for a replay.
176    pub fn check_replay_and_record(&mut self, request_id: &str) -> Result<(), DashboardError> {
177        if !self.config.replay_protection.enabled {
178            return Ok(());
179        }
180        self.replay_window.check_and_record(request_id)
181    }
182
183    /// Checks the idempotency cache for a cached response (C8).
184    ///
185    /// Called after `check()` passes but before dispatch.
186    ///
187    /// # Arguments
188    ///
189    /// - `request_id`: Request identifier.
190    ///
191    /// # Returns
192    ///
193    /// Returns `Some(cached_result_json)` if a cached result exists,
194    /// or `None` if no cache hit.
195    pub fn check_idempotency(&self, request_id: &str) -> Option<String> {
196        if self.config.idempotency.enabled {
197            self.idempotency_cache.get(request_id)
198        } else {
199            None
200        }
201    }
202
203    /// Caches a dispatch result for idempotency (C8).
204    ///
205    /// # Arguments
206    ///
207    /// - `request_id`: Request identifier.
208    /// - `response_json`: Serialized response to cache.
209    pub fn cache_result(&mut self, request_id: &str, response_json: &str) {
210        if self.config.idempotency.enabled {
211            self.idempotency_cache
212                .put(request_id.to_string(), response_json.to_string());
213        }
214    }
215
216    /// Returns the C5 request size limit in bytes, or `None` if disabled.
217    pub fn request_size_limit_bytes(&self) -> Option<usize> {
218        if self.config.request_size_limit.enabled {
219            Some(self.config.request_size_limit.max_bytes)
220        } else {
221            None
222        }
223    }
224
225    /// Returns the configured high-risk command method list.
226    ///
227    /// This is the list from `AuthorizationConfig.high_risk_commands`.
228    /// When empty (the config is not populated), the caller should
229    /// fall back to a hardcoded default set.
230    pub fn high_risk_methods(&self) -> &[String] {
231        &self.config.authorization.high_risk_commands
232    }
233
234    /// Checks whether an external command path is allowed by C9 allowlist.
235    ///
236    /// This is called at extension points where external executables may
237    /// be invoked under supervisor control. The allowlist rejects paths
238    /// that are not explicitly configured.
239    ///
240    /// # Arguments
241    ///
242    /// - `path`: Absolute executable path to check.
243    ///
244    /// # Returns
245    ///
246    /// Returns `Ok(())` when the path is allowed, or
247    /// `Err(DashboardError)` with `allowlist_empty` or `allowlist_denied`.
248    pub fn check_ext_command_allowlist(&self, path: &str) -> Result<(), DashboardError> {
249        crate::ipc::security::allowlist::check_allowlist(path, &self.config.allowlist)
250    }
251
252    /// Returns a reference to the audit backend for health checks.
253    pub fn audit_backend(&self) -> &audit::AuditBackend {
254        &self.audit
255    }
256
257    /// Returns the failure strategy from config.
258    fn audit_failure_strategy(&self) -> audit::AuditFailureStrategy {
259        audit::AuditFailureStrategy::from_config(&self.audit_config.failure_strategy)
260    }
261
262    /// Writes an audit record after dispatch (C7).
263    ///
264    /// Returns `Ok(())` on success or `Err(DashboardError)` when the audit
265    /// backend is unwritable. The caller should fail closed for high-risk
266    /// commands.
267    ///
268    /// When `is_high_risk` is `true` and the configured failure strategy is
269    /// `fail_closed`, any backend write error is propagated so the caller
270    /// can reject the command. When `defer_bounded`, errors are logged but
271    /// not propagated.
272    ///
273    /// # Arguments
274    ///
275    /// - `method`: IPC method name.
276    /// - `peer_identity`: Peer identity snapshot.
277    /// - `allowed`: Whether the request was allowed.
278    /// - `denial_error`: The denial error if denied.
279    /// - `denial_control_point`: Which control point denied (C1-C9 or "dispatch").
280    /// - `is_high_risk`: Whether this is a high-risk command (e.g. restart, shutdown).
281    ///
282    /// # Returns
283    ///
284    /// Returns `Ok(())` when the audit record was written, or
285    /// `Err(DashboardError)` when the backend is unwritable and the
286    /// failure strategy requires fail-closed.
287    pub fn write_audit(
288        &mut self,
289        method: &str,
290        peer_identity: &peer_identity::PeerIdentity,
291        allowed: bool,
292        denial_error: Option<&DashboardError>,
293        denial_control_point: &str,
294        is_high_risk: bool,
295    ) -> Result<(), DashboardError> {
296        if !self.audit_config.enabled {
297            return Ok(());
298        }
299        let hash = format!(
300            "uid:{uid}:pid:{pid}",
301            uid = peer_identity.uid,
302            pid = peer_identity.pid
303        );
304        let now = std::time::SystemTime::now()
305            .duration_since(std::time::UNIX_EPOCH)
306            .unwrap_or_default()
307            .as_secs()
308            .to_string();
309        let record = AuditRecord {
310            timestamp: now,
311            method: method.to_string(),
312            initiator_hash: hash,
313            correlation_id: None,
314            allowed,
315            denial_code: denial_error.map(|e| e.code.clone()),
316            denial_control_point: if allowed {
317                None
318            } else {
319                Some(denial_control_point.to_string())
320            },
321        };
322        let strategy = self.audit_failure_strategy();
323        let write_result = self.audit.write(&record);
324        match write_result {
325            Ok(()) => Ok(()),
326            Err(err) => {
327                let count = audit::alerts::increment_failure_count();
328                tracing::error!(
329                    target: "rust_supervisor::ipc::security::audit",
330                    failure_count = count,
331                    ?err,
332                    high_risk = is_high_risk,
333                    strategy = ?strategy,
334                    "audit write failed"
335                );
336                // fail_closed + high-risk → propagate error so caller can reject
337                if is_high_risk && strategy == audit::AuditFailureStrategy::FailClosed {
338                    Err(err)
339                } else {
340                    // defer_bounded or non-high-risk: log only, swallow error
341                    Ok(())
342                }
343            }
344        }
345    }
346}