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}