Skip to main content

orcs_auth/
grant.rs

1//! Dynamic command permission grants.
2//!
3//! Provides types for granting and revoking command execution permissions
4//! at runtime. This is the **dynamic** counterpart to [`crate::Capability`] (static WHAT).
5//!
6//! # Auth Model
7//!
8//! ```text
9//! Effective Permission =
10//!     Capability(static WHAT)
11//!   ∩ SandboxPolicy(WHERE)
12//!   ∩ Session(WHO + WHEN)
13//!   ∩ GrantPolicy(dynamic WHAT — modified by Grant/Revoke operations)
14//! ```
15//!
16//! # Grant vs Capability
17//!
18//! | Aspect | Capability | Grant |
19//! |--------|-----------|-------|
20//! | Defined | At component creation | At runtime (e.g., HIL approval) |
21//! | Scope | Logical operations (READ, WRITE, ...) | Command patterns ("rm -rf", "git push") |
22//! | Mutability | Immutable (inherited, narrowing only) | Mutable (grant/revoke) |
23//! | Lifetime | Component lifetime | Session lifetime or one-time |
24//!
25//! # Architecture
26//!
27//! ```text
28//! GrantPolicy trait (orcs-auth)   ← trait definition (THIS MODULE)
29//!          │
30//!          └── DefaultGrantStore (orcs-runtime)   ← concrete impl
31//! ```
32
33use serde::{Deserialize, Serialize};
34use thiserror::Error;
35
36/// Error returned by grant operations that access internal state.
37#[derive(Debug, Error)]
38pub enum GrantError {
39    /// Internal lock was poisoned (a thread panicked while holding it).
40    #[error("grant store lock poisoned: {context}")]
41    LockPoisoned {
42        /// Which lock was poisoned.
43        context: String,
44    },
45}
46
47/// A dynamic permission grant for a command pattern.
48///
49/// Represents the result of a permission-changing operation (e.g., HIL approval).
50///
51/// # Example
52///
53/// ```
54/// use orcs_auth::{CommandGrant, GrantKind};
55///
56/// // Persistent grant (lasts until session ends or revoked)
57/// let grant = CommandGrant::persistent("rm -rf");
58/// assert_eq!(grant.pattern, "rm -rf");
59/// assert_eq!(grant.kind, GrantKind::Persistent);
60///
61/// // One-time grant (consumed on first use)
62/// let grant = CommandGrant::one_time("git push --force");
63/// assert_eq!(grant.kind, GrantKind::OneTime);
64/// ```
65#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct CommandGrant {
67    /// The command pattern to match (prefix match).
68    pub pattern: String,
69    /// How long this grant is valid.
70    pub kind: GrantKind,
71}
72
73impl CommandGrant {
74    /// Creates a persistent grant (valid until session ends or revoked).
75    #[must_use]
76    pub fn persistent(pattern: impl Into<String>) -> Self {
77        Self {
78            pattern: pattern.into(),
79            kind: GrantKind::Persistent,
80        }
81    }
82
83    /// Creates a one-time grant (consumed on first use).
84    #[must_use]
85    pub fn one_time(pattern: impl Into<String>) -> Self {
86        Self {
87            pattern: pattern.into(),
88            kind: GrantKind::OneTime,
89        }
90    }
91}
92
93/// The lifetime of a [`CommandGrant`].
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
95pub enum GrantKind {
96    /// Valid until the session ends or the grant is explicitly revoked.
97    Persistent,
98    /// Consumed after a single use.
99    OneTime,
100}
101
102/// Dynamic command permission management.
103///
104/// Abstracts the "dynamic WHAT" layer of the permission model.
105/// Implementations store granted patterns and answer queries.
106///
107/// # Thread Safety
108///
109/// Implementations must be `Send + Sync` for use across async boundaries.
110///
111/// # Example
112///
113/// ```
114/// use orcs_auth::{GrantPolicy, CommandGrant};
115///
116/// fn check_with_grants(grants: &dyn GrantPolicy, cmd: &str) -> bool {
117///     grants.is_granted(cmd).unwrap_or(false)
118/// }
119/// ```
120pub trait GrantPolicy: Send + Sync + std::fmt::Debug {
121    /// Grants a command pattern.
122    ///
123    /// After granting, commands matching this pattern will be allowed
124    /// by [`is_granted`](Self::is_granted).
125    ///
126    /// # Errors
127    ///
128    /// Returns [`GrantError`] if internal state is inaccessible.
129    fn grant(&self, grant: CommandGrant) -> Result<(), GrantError>;
130
131    /// Revokes a previously granted pattern.
132    ///
133    /// The pattern must match exactly (not prefix match).
134    ///
135    /// # Errors
136    ///
137    /// Returns [`GrantError`] if internal state is inaccessible.
138    fn revoke(&self, pattern: &str) -> Result<(), GrantError>;
139
140    /// Checks if a command is allowed by any granted pattern.
141    ///
142    /// Uses **prefix matching**: returns `true` if the command starts
143    /// with any granted pattern. One-time grants are consumed on match.
144    ///
145    /// # Errors
146    ///
147    /// Returns [`GrantError`] if internal state is inaccessible.
148    fn is_granted(&self, command: &str) -> Result<bool, GrantError>;
149
150    /// Clears all grants.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`GrantError`] if internal state is inaccessible.
155    fn clear(&self) -> Result<(), GrantError>;
156
157    /// Returns the number of active grants.
158    fn grant_count(&self) -> usize;
159
160    /// Returns all currently active grants.
161    ///
162    /// This is a **trait-level operation** (not an impl-specific convenience).
163    /// Any `GrantPolicy` implementation — whether backed by local memory,
164    /// a remote store, or a database — must be able to enumerate its grants
165    /// so that callers (e.g., session persistence) can work through
166    /// `dyn GrantPolicy` without knowing the concrete type (OCP).
167    ///
168    /// # Errors
169    ///
170    /// Returns [`GrantError`] if internal state is inaccessible.
171    ///
172    /// # Notes
173    ///
174    /// - OneTime grants are included (they haven't been consumed yet)
175    /// - The order of returned grants is unspecified
176    fn list_grants(&self) -> Result<Vec<CommandGrant>, GrantError>;
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn command_grant_persistent() {
185        let grant = CommandGrant::persistent("rm -rf");
186        assert_eq!(grant.pattern, "rm -rf");
187        assert_eq!(grant.kind, GrantKind::Persistent);
188    }
189
190    #[test]
191    fn command_grant_one_time() {
192        let grant = CommandGrant::one_time("git push --force");
193        assert_eq!(grant.pattern, "git push --force");
194        assert_eq!(grant.kind, GrantKind::OneTime);
195    }
196
197    #[test]
198    fn command_grant_equality() {
199        let a = CommandGrant::persistent("rm -rf");
200        let b = CommandGrant::persistent("rm -rf");
201        assert_eq!(a, b);
202
203        let c = CommandGrant::one_time("rm -rf");
204        assert_ne!(a, c); // Different kind
205    }
206
207    #[test]
208    fn serde_roundtrip() {
209        let grant = CommandGrant::persistent("rm -rf");
210        let json = serde_json::to_string(&grant).expect("serialize");
211        let parsed: CommandGrant = serde_json::from_str(&json).expect("deserialize");
212        assert_eq!(parsed, grant);
213    }
214
215    #[test]
216    fn grant_kind_serde() {
217        let kind = GrantKind::OneTime;
218        let json = serde_json::to_string(&kind).expect("serialize");
219        let parsed: GrantKind = serde_json::from_str(&json).expect("deserialize");
220        assert_eq!(parsed, kind);
221    }
222}