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}