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}