Skip to main content

orcs_runtime/auth/
checker.rs

1//! Permission checking types and policies.
2//!
3//! This module provides the [`PermissionChecker`] trait for policy-based
4//! permission decisions, and [`DefaultPolicy`] as a standard implementation.
5//!
6//! # Design
7//!
8//! Permission checking is separated from the types themselves:
9//!
10//! - [`Session`](crate::Session): Holds identity and privilege level
11//! - [`SignalScope`](orcs_types::SignalScope): Defines affected scope
12//! - [`PermissionChecker`]: Decides if session can act on scope
13//!
14//! This separation allows:
15//!
16//! - Different policies for different environments
17//! - Testing with mock policies
18//! - Policy changes without modifying core types
19//!
20//! # Example
21//!
22//! ```
23//! use orcs_runtime::{Principal, Session, PermissionChecker, DefaultPolicy};
24//! use orcs_auth::PermissionPolicy;
25//! use orcs_types::{PrincipalId, SignalScope, ChannelId};
26//! use std::time::Duration;
27//!
28//! let policy = DefaultPolicy;
29//! let session = Session::new(Principal::User(PrincipalId::new()));
30//!
31//! // Standard session cannot signal Global scope
32//! assert!(!policy.can_signal(&session, &SignalScope::Global));
33//!
34//! // But can signal Channel scope
35//! let channel = ChannelId::new();
36//! assert!(policy.can_signal(&session, &SignalScope::Channel(channel)));
37//!
38//! // Elevated session can signal Global scope
39//! let elevated = session.elevate(Duration::from_secs(60));
40//! assert!(policy.can_signal(&elevated, &SignalScope::Global));
41//! ```
42
43use super::command_check::CommandCheckResult;
44use super::Session;
45use crate::components::ApprovalRequest;
46use orcs_auth::{GrantPolicy, PermissionPolicy};
47use orcs_types::SignalScope;
48
49/// Runtime-level permission checker with HIL integration.
50///
51/// Extends [`PermissionPolicy`] (from `orcs-auth`) with
52/// [`check_command`](PermissionChecker::check_command) that returns
53/// [`CommandCheckResult`] (including `ApprovalRequest` for HIL flow).
54///
55/// # Architecture
56///
57/// ```text
58/// PermissionPolicy (orcs-auth)     <- abstract, no runtime deps
59///        ↑ supertrait
60/// PermissionChecker (THIS TRAIT)   <- adds check_command(CommandCheckResult)
61///        │
62///        └── DefaultPolicy         <- concrete impl
63/// ```
64///
65/// # Example Implementation
66///
67/// ```
68/// use orcs_runtime::{PermissionChecker, Session};
69/// use orcs_auth::PermissionPolicy;
70/// use orcs_types::SignalScope;
71///
72/// struct StrictPolicy;
73///
74/// impl PermissionPolicy for StrictPolicy {
75///     fn can_signal(&self, session: &Session, _scope: &SignalScope) -> bool {
76///         session.is_elevated()
77///     }
78///
79///     fn can_destructive(&self, session: &Session, _action: &str) -> bool {
80///         session.is_elevated()
81///     }
82///
83///     fn can_execute_command(&self, session: &Session, _cmd: &str) -> bool {
84///         session.is_elevated()
85///     }
86///
87///     fn can_spawn_child(&self, session: &Session) -> bool {
88///         session.is_elevated()
89///     }
90///
91///     fn can_spawn_runner(&self, session: &Session) -> bool {
92///         session.is_elevated()
93///     }
94/// }
95///
96/// impl PermissionChecker for StrictPolicy {}
97/// ```
98pub trait PermissionChecker: PermissionPolicy {
99    /// Check command with granular result including HIL approval flow.
100    ///
101    /// This is the preferred method for command checking as it supports:
102    ///
103    /// - Dynamic grants (previously approved commands via [`GrantPolicy`])
104    /// - HIL approval flow (RequiresApproval)
105    /// - Detailed denial reasons
106    ///
107    /// # Arguments
108    ///
109    /// * `session` - The current session (identity + privilege level)
110    /// * `grants` - Dynamic command grants (previously approved patterns)
111    /// * `cmd` - The command to check
112    ///
113    /// # Default Implementation
114    ///
115    /// Wraps `can_execute_command`:
116    /// - Returns `Allowed` if `can_execute_command` returns true
117    /// - Returns `Denied` otherwise
118    ///
119    /// Override this method to implement HIL integration.
120    fn check_command(
121        &self,
122        session: &Session,
123        grants: &dyn GrantPolicy,
124        cmd: &str,
125    ) -> CommandCheckResult {
126        let granted = grants.is_granted(cmd).unwrap_or_else(|e| {
127            tracing::error!("grant check failed in default check_command: {e}");
128            false
129        });
130        if self.can_execute_command(session, cmd) || granted {
131            CommandCheckResult::Allowed
132        } else {
133            CommandCheckResult::Denied("permission denied".to_string())
134        }
135    }
136}
137
138/// Default permission policy.
139///
140/// # Rules
141///
142/// | Scope/Action | Standard | Elevated |
143/// |--------------|----------|----------|
144/// | Global signal | Denied | Allowed |
145/// | Channel signal | Allowed | Allowed |
146/// | Destructive ops | Denied | Allowed |
147/// | Command exec | Denied | Allowed |
148/// | Spawn child/runner | Denied | Allowed |
149///
150/// # Security Model
151///
152/// Command safety is enforced at the OS sandbox layer (SandboxPolicy),
153/// not by pattern-matching commands. This policy controls WHO can act
154/// (session-based), while SandboxPolicy controls WHERE actions reach.
155///
156/// # Audit Logging
157///
158/// All permission checks are logged for audit:
159/// - Allowed operations: debug level
160/// - Denied operations: warn level
161#[derive(Debug, Clone, Copy, Default)]
162pub struct DefaultPolicy;
163
164impl PermissionPolicy for DefaultPolicy {
165    fn can_signal(&self, session: &Session, scope: &SignalScope) -> bool {
166        let allowed = match scope {
167            SignalScope::Global => session.is_elevated(),
168            SignalScope::Channel(_) | SignalScope::WithChildren(_) => true,
169        };
170
171        // Audit logging
172        if allowed {
173            tracing::debug!(
174                principal = ?session.principal(),
175                elevated = session.is_elevated(),
176                scope = ?scope,
177                "signal allowed"
178            );
179        } else {
180            tracing::warn!(
181                principal = ?session.principal(),
182                elevated = session.is_elevated(),
183                scope = ?scope,
184                "signal denied: requires elevation"
185            );
186        }
187
188        allowed
189    }
190
191    fn can_destructive(&self, session: &Session, action: &str) -> bool {
192        let allowed = session.is_elevated();
193
194        // Audit logging
195        if allowed {
196            tracing::info!(
197                principal = ?session.principal(),
198                action = action,
199                "destructive operation allowed"
200            );
201        } else {
202            tracing::warn!(
203                principal = ?session.principal(),
204                action = action,
205                "destructive operation denied: requires elevation"
206            );
207        }
208
209        allowed
210    }
211
212    fn can_execute_command(&self, session: &Session, cmd: &str) -> bool {
213        let allowed = session.is_elevated();
214
215        if allowed {
216            tracing::debug!(
217                principal = ?session.principal(),
218                cmd = cmd,
219                "command allowed"
220            );
221        } else {
222            tracing::warn!(
223                principal = ?session.principal(),
224                cmd = cmd,
225                "command denied: requires elevation"
226            );
227        }
228
229        allowed
230    }
231
232    fn can_spawn_child(&self, session: &Session) -> bool {
233        let allowed = session.is_elevated();
234
235        // Audit logging
236        if allowed {
237            tracing::debug!(
238                principal = ?session.principal(),
239                "spawn_child allowed"
240            );
241        } else {
242            tracing::warn!(
243                principal = ?session.principal(),
244                "spawn_child denied: requires elevation"
245            );
246        }
247
248        allowed
249    }
250
251    fn can_spawn_runner(&self, session: &Session) -> bool {
252        let allowed = session.is_elevated();
253
254        // Audit logging
255        if allowed {
256            tracing::debug!(
257                principal = ?session.principal(),
258                "spawn_runner allowed"
259            );
260        } else {
261            tracing::warn!(
262                principal = ?session.principal(),
263                "spawn_runner denied: requires elevation"
264            );
265        }
266
267        allowed
268    }
269}
270
271impl PermissionChecker for DefaultPolicy {
272    /// Check command with dynamic grants and HIL approval support.
273    ///
274    /// Flow:
275    /// 1. Reject empty commands
276    /// 2. Check dynamic grants (via [`GrantPolicy`]) -> Allowed
277    /// 3. Check elevated session -> Allowed
278    /// 4. Otherwise -> RequiresApproval (non-elevated session)
279    ///
280    /// Command safety is enforced at the OS sandbox layer, not here.
281    /// This method only controls WHO can execute, not WHAT can be executed.
282    fn check_command(
283        &self,
284        session: &Session,
285        grants: &dyn GrantPolicy,
286        cmd: &str,
287    ) -> CommandCheckResult {
288        let cmd_trimmed = cmd.trim();
289        if cmd_trimmed.is_empty() {
290            tracing::debug!("command denied: empty command");
291            return CommandCheckResult::Denied("empty command".to_string());
292        }
293
294        // Step 1: Check dynamic grants (previously approved via HIL)
295        let granted = grants.is_granted(cmd).unwrap_or_else(|e| {
296            tracing::error!("grant check failed: {e}");
297            false
298        });
299        if granted {
300            tracing::debug!(
301                principal = ?session.principal(),
302                cmd = cmd,
303                "command allowed: previously granted"
304            );
305            return CommandCheckResult::Allowed;
306        }
307
308        // Step 2: Delegate to can_execute_command (elevation check + audit logging)
309        if self.can_execute_command(session, cmd) {
310            return CommandCheckResult::Allowed;
311        }
312
313        // Step 3: Non-elevated sessions require approval
314        let cmd_base = cmd.split_whitespace().next().unwrap_or(cmd);
315        tracing::info!(
316            principal = ?session.principal(),
317            cmd = cmd,
318            pattern = cmd_base,
319            "command requires approval (non-elevated session)"
320        );
321
322        let request = ApprovalRequest::new(
323            "exec",
324            format!("Execute command: {}", cmd),
325            serde_json::json!({
326                "command": cmd,
327                "pattern": cmd_base,
328            }),
329        );
330
331        CommandCheckResult::RequiresApproval {
332            request,
333            grant_pattern: cmd_base.to_string(),
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::auth::DefaultGrantStore;
342    use orcs_auth::CommandGrant;
343    use orcs_types::{ChannelId, Principal, PrincipalId};
344    use std::time::Duration;
345
346    fn standard_session() -> Session {
347        Session::new(Principal::User(PrincipalId::new()))
348    }
349
350    fn elevated_session() -> Session {
351        standard_session().elevate(Duration::from_secs(60))
352    }
353
354    fn empty_grants() -> DefaultGrantStore {
355        DefaultGrantStore::new()
356    }
357
358    #[test]
359    fn standard_cannot_signal_global() {
360        let policy = DefaultPolicy;
361        let session = standard_session();
362
363        assert!(!policy.can_signal(&session, &SignalScope::Global));
364    }
365
366    #[test]
367    fn standard_can_signal_channel() {
368        let policy = DefaultPolicy;
369        let session = standard_session();
370        let channel = ChannelId::new();
371
372        assert!(policy.can_signal(&session, &SignalScope::Channel(channel)));
373    }
374
375    #[test]
376    fn elevated_can_signal_global() {
377        let policy = DefaultPolicy;
378        let session = elevated_session();
379
380        assert!(policy.can_signal(&session, &SignalScope::Global));
381    }
382
383    #[test]
384    fn elevated_can_signal_channel() {
385        let policy = DefaultPolicy;
386        let session = elevated_session();
387        let channel = ChannelId::new();
388
389        assert!(policy.can_signal(&session, &SignalScope::Channel(channel)));
390    }
391
392    #[test]
393    fn standard_cannot_destructive() {
394        let policy = DefaultPolicy;
395        let session = standard_session();
396
397        assert!(!policy.can_destructive(&session, "git reset --hard"));
398        assert!(!policy.can_destructive(&session, "rm -rf"));
399    }
400
401    #[test]
402    fn elevated_can_destructive() {
403        let policy = DefaultPolicy;
404        let session = elevated_session();
405
406        assert!(policy.can_destructive(&session, "git reset --hard"));
407        assert!(policy.can_destructive(&session, "rm -rf"));
408    }
409
410    #[test]
411    fn system_session_standard() {
412        let policy = DefaultPolicy;
413        let session = Session::new(Principal::System);
414
415        // System is also Standard by default
416        assert!(!policy.can_signal(&session, &SignalScope::Global));
417        assert!(policy.can_signal(&session, &SignalScope::Channel(ChannelId::new())));
418    }
419
420    #[test]
421    fn dropped_privilege_cannot_global() {
422        let policy = DefaultPolicy;
423        let session = elevated_session().drop_privilege();
424
425        assert!(!policy.can_signal(&session, &SignalScope::Global));
426    }
427
428    #[test]
429    fn standard_cannot_execute_command() {
430        let policy = DefaultPolicy;
431        let session = standard_session();
432
433        assert!(!policy.can_execute_command(&session, "ls -la"));
434        assert!(!policy.can_execute_command(&session, "rm -rf ./temp"));
435    }
436
437    #[test]
438    fn elevated_can_execute_any_command() {
439        let policy = DefaultPolicy;
440        let session = elevated_session();
441
442        // Elevated can execute any command — safety is enforced by OS sandbox
443        assert!(policy.can_execute_command(&session, "ls -la"));
444        assert!(policy.can_execute_command(&session, "rm -rf ./target"));
445        assert!(policy.can_execute_command(&session, "rm -rf /"));
446        assert!(policy.can_execute_command(&session, "git push --force"));
447    }
448
449    #[test]
450    fn standard_cannot_spawn_child() {
451        let policy = DefaultPolicy;
452        let session = standard_session();
453
454        assert!(!policy.can_spawn_child(&session));
455    }
456
457    #[test]
458    fn elevated_can_spawn_child() {
459        let policy = DefaultPolicy;
460        let session = elevated_session();
461
462        assert!(policy.can_spawn_child(&session));
463    }
464
465    #[test]
466    fn standard_cannot_spawn_runner() {
467        let policy = DefaultPolicy;
468        let session = standard_session();
469
470        assert!(!policy.can_spawn_runner(&session));
471    }
472
473    #[test]
474    fn elevated_can_spawn_runner() {
475        let policy = DefaultPolicy;
476        let session = elevated_session();
477
478        assert!(policy.can_spawn_runner(&session));
479    }
480
481    // =========================================================================
482    // check_command Tests
483    // =========================================================================
484
485    #[test]
486    fn check_command_requires_approval_when_not_elevated() {
487        let policy = DefaultPolicy;
488        let session = standard_session();
489        let grants = empty_grants();
490
491        let result = policy.check_command(&session, &grants, "ls -la");
492        assert!(result.requires_approval());
493        assert_eq!(result.grant_pattern(), Some("ls"));
494    }
495
496    #[test]
497    fn check_command_elevated_session_allowed() {
498        let policy = DefaultPolicy;
499        let session = elevated_session();
500        let grants = empty_grants();
501
502        // Elevated sessions can execute any command
503        let result = policy.check_command(&session, &grants, "rm -rf ./temp");
504        assert!(result.is_allowed());
505
506        let result = policy.check_command(&session, &grants, "rm -rf /");
507        assert!(result.is_allowed());
508    }
509
510    #[test]
511    fn check_command_granted_command_allowed() {
512        let policy = DefaultPolicy;
513        let session = standard_session();
514        let grants = DefaultGrantStore::new();
515
516        grants
517            .grant(CommandGrant::persistent("rm -rf"))
518            .expect("grant persistent for check_command test");
519
520        let result = policy.check_command(&session, &grants, "rm -rf ./temp");
521        assert!(result.is_allowed());
522    }
523
524    #[test]
525    fn check_command_empty_returns_denied() {
526        let policy = DefaultPolicy;
527        let session = elevated_session();
528        let grants = empty_grants();
529
530        let result = policy.check_command(&session, &grants, "  ");
531        assert!(result.is_denied());
532    }
533
534    #[test]
535    fn check_command_any_cmd_requires_approval_for_standard() {
536        let policy = DefaultPolicy;
537        let session = standard_session();
538        let grants = empty_grants();
539
540        // All commands require approval for standard sessions
541        let result = policy.check_command(&session, &grants, "git push --force origin main");
542        assert!(result.requires_approval());
543        assert_eq!(result.grant_pattern(), Some("git"));
544    }
545}