Skip to main content

orcs_runtime/auth/
grant_store.rs

1//! Default implementation of [`GrantPolicy`].
2//!
3//! Provides [`DefaultGrantStore`] — a thread-safe, in-memory grant store
4//! that manages dynamic command permissions.
5//!
6//! # Architecture
7//!
8//! ```text
9//! GrantPolicy trait (orcs-auth)       ← abstract definition
10//!          │
11//!          └── DefaultGrantStore (THIS MODULE)  ← concrete impl
12//! ```
13//!
14//! # Grant Matching
15//!
16//! Uses **prefix matching**: a command is granted if it starts with
17//! any stored pattern. One-time grants are consumed on first match.
18
19use orcs_auth::{CommandGrant, GrantError, GrantKind, GrantPolicy};
20use std::collections::HashSet;
21use std::sync::RwLock;
22
23/// Thread-safe, in-memory command grant store.
24///
25/// Implements [`GrantPolicy`] using a single `RwLock` over both grant sets
26/// for atomic snapshot consistency across all operations.
27///
28/// # Grant Types
29///
30/// | Type | Behavior |
31/// |------|----------|
32/// | Persistent | Valid until revoked or session ends |
33/// | OneTime | Consumed on first successful match |
34///
35/// # Thread Safety
36///
37/// All operations are thread-safe via a single `RwLock`.
38/// Because one-time grants are consumed on match, [`is_granted`](GrantPolicy::is_granted)
39/// acquires a **write lock** to ensure atomicity. Read-only accessors
40/// (`grant_count`, `list_grants`) use a read lock for concurrency.
41///
42/// # Example
43///
44/// ```
45/// use orcs_auth::{CommandGrant, GrantPolicy};
46/// use orcs_runtime::DefaultGrantStore;
47///
48/// let store = DefaultGrantStore::new();
49///
50/// // Grant a persistent pattern
51/// store.grant(CommandGrant::persistent("rm -rf")).expect("grant persistent");
52/// assert!(store.is_granted("rm -rf ./temp").expect("check grant"));
53/// assert!(store.is_granted("rm -rf ./temp").expect("check grant")); // Still valid
54///
55/// // Grant a one-time pattern
56/// store.grant(CommandGrant::one_time("git push --force")).expect("grant one-time");
57/// assert!(store.is_granted("git push --force origin main").expect("check grant")); // Consumed
58/// assert!(!store.is_granted("git push --force origin main").expect("check grant")); // Gone
59/// ```
60#[derive(Debug, Default)]
61pub struct DefaultGrantStore {
62    /// Both grant sets behind a single lock for atomic access.
63    inner: RwLock<GrantSets>,
64}
65
66/// Internal storage for grant patterns.
67#[derive(Debug, Default)]
68struct GrantSets {
69    /// Persistent grants (survive until revoked).
70    persistent: HashSet<String>,
71    /// One-time grants (consumed on first match).
72    one_time: HashSet<String>,
73}
74
75impl DefaultGrantStore {
76    /// Creates a new empty grant store.
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Restores grants from a previously saved list (batch, single lock).
83    ///
84    /// Typically used to restore session state:
85    /// ```ignore
86    /// let grants = old_store.list_grants().expect("list grants");
87    /// // ... serialize grants to SessionAsset, persist, reload ...
88    /// let new_store = DefaultGrantStore::new();
89    /// new_store.restore_grants(&grants).expect("restore grants");
90    /// ```
91    ///
92    /// Existing grants are preserved (additive merge).
93    ///
94    /// # Errors
95    ///
96    /// Returns [`GrantError`] if internal state is inaccessible.
97    pub fn restore_grants(&self, grants: &[CommandGrant]) -> Result<(), GrantError> {
98        let mut inner = self.inner.write().map_err(|_| GrantError::LockPoisoned {
99            context: "grant store".into(),
100        })?;
101        for grant in grants {
102            match grant.kind {
103                GrantKind::Persistent => {
104                    inner.persistent.insert(grant.pattern.clone());
105                }
106                GrantKind::OneTime => {
107                    inner.one_time.insert(grant.pattern.clone());
108                }
109            }
110        }
111        Ok(())
112    }
113
114    /// Helper: acquire write lock.
115    fn write_inner(&self) -> Result<std::sync::RwLockWriteGuard<'_, GrantSets>, GrantError> {
116        self.inner.write().map_err(|_| GrantError::LockPoisoned {
117            context: "grant store".into(),
118        })
119    }
120
121    /// Helper: acquire read lock.
122    fn read_inner(&self) -> Result<std::sync::RwLockReadGuard<'_, GrantSets>, GrantError> {
123        self.inner.read().map_err(|_| GrantError::LockPoisoned {
124            context: "grant store".into(),
125        })
126    }
127}
128
129impl GrantPolicy for DefaultGrantStore {
130    fn grant(&self, grant: CommandGrant) -> Result<(), GrantError> {
131        let mut inner = self.write_inner()?;
132        match grant.kind {
133            GrantKind::Persistent => {
134                inner.persistent.insert(grant.pattern);
135            }
136            GrantKind::OneTime => {
137                inner.one_time.insert(grant.pattern);
138            }
139        }
140        Ok(())
141    }
142
143    fn revoke(&self, pattern: &str) -> Result<(), GrantError> {
144        let mut inner = self.write_inner()?;
145        inner.persistent.remove(pattern);
146        inner.one_time.remove(pattern);
147        Ok(())
148    }
149
150    fn is_granted(&self, command: &str) -> Result<bool, GrantError> {
151        let mut inner = self.write_inner()?;
152
153        // Check persistent grants (not consumed)
154        if inner
155            .persistent
156            .iter()
157            .any(|p| command == p.as_str() || prefix_matches(command, p))
158        {
159            return Ok(true);
160        }
161
162        // Check one-time grants (consume on match)
163        if let Some(pattern) = inner
164            .one_time
165            .iter()
166            .find(|p| command == p.as_str() || prefix_matches(command, p))
167            .cloned()
168        {
169            inner.one_time.remove(&pattern);
170            return Ok(true);
171        }
172
173        Ok(false)
174    }
175
176    fn clear(&self) -> Result<(), GrantError> {
177        let mut inner = self.write_inner()?;
178        inner.persistent.clear();
179        inner.one_time.clear();
180        Ok(())
181    }
182
183    fn grant_count(&self) -> usize {
184        self.read_inner()
185            .map(|inner| inner.persistent.len().saturating_add(inner.one_time.len()))
186            .unwrap_or(0)
187    }
188
189    fn list_grants(&self) -> Result<Vec<CommandGrant>, GrantError> {
190        let inner = self.read_inner()?;
191        let mut grants = Vec::with_capacity(inner.persistent.len() + inner.one_time.len());
192        grants.extend(
193            inner
194                .persistent
195                .iter()
196                .map(|p| CommandGrant::persistent(p.as_str())),
197        );
198        grants.extend(
199            inner
200                .one_time
201                .iter()
202                .map(|p| CommandGrant::one_time(p.as_str())),
203        );
204        Ok(grants)
205    }
206}
207
208/// Checks if `command` starts with `pattern` followed by a space.
209///
210/// Avoids `format!` allocation on every comparison.
211fn prefix_matches(command: &str, pattern: &str) -> bool {
212    command.starts_with(pattern) && command.as_bytes().get(pattern.len()) == Some(&b' ')
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn new_store_is_empty() {
221        let store = DefaultGrantStore::new();
222        assert_eq!(store.grant_count(), 0);
223        assert!(!store
224            .is_granted("anything")
225            .expect("is_granted on empty store"));
226    }
227
228    #[test]
229    fn persistent_grant_survives_multiple_checks() {
230        let store = DefaultGrantStore::new();
231        store
232            .grant(CommandGrant::persistent("rm -rf"))
233            .expect("grant persistent");
234
235        assert!(store.is_granted("rm -rf ./temp").expect("check 1"));
236        assert!(store.is_granted("rm -rf ./other").expect("check 2"));
237        assert!(store
238            .is_granted("rm -rf ./temp")
239            .expect("check 3 still valid"));
240        assert_eq!(store.grant_count(), 1);
241    }
242
243    #[test]
244    fn one_time_grant_consumed_on_match() {
245        let store = DefaultGrantStore::new();
246        store
247            .grant(CommandGrant::one_time("git push --force"))
248            .expect("grant one-time");
249
250        assert_eq!(store.grant_count(), 1);
251        assert!(store
252            .is_granted("git push --force origin main")
253            .expect("first check consumes"));
254        assert_eq!(store.grant_count(), 0);
255        assert!(!store
256            .is_granted("git push --force origin main")
257            .expect("second check after consumed"));
258    }
259
260    #[test]
261    fn prefix_matching() {
262        let store = DefaultGrantStore::new();
263        store
264            .grant(CommandGrant::persistent("rm -rf"))
265            .expect("grant persistent");
266
267        assert!(store.is_granted("rm -rf ./temp").expect("prefix match"));
268        assert!(store
269            .is_granted("rm -rf /home/user/stuff")
270            .expect("prefix match long"));
271        assert!(!store.is_granted("rm ./temp").expect("no prefix match"));
272        assert!(!store.is_granted("ls -la").expect("unrelated cmd"));
273    }
274
275    #[test]
276    fn revoke_persistent() {
277        let store = DefaultGrantStore::new();
278        store
279            .grant(CommandGrant::persistent("rm -rf"))
280            .expect("grant persistent");
281        assert!(store.is_granted("rm -rf ./temp").expect("before revoke"));
282
283        store.revoke("rm -rf").expect("revoke persistent");
284        assert!(!store.is_granted("rm -rf ./temp").expect("after revoke"));
285        assert_eq!(store.grant_count(), 0);
286    }
287
288    #[test]
289    fn revoke_one_time() {
290        let store = DefaultGrantStore::new();
291        store
292            .grant(CommandGrant::one_time("git push --force"))
293            .expect("grant one-time");
294
295        store.revoke("git push --force").expect("revoke one-time");
296        assert!(!store
297            .is_granted("git push --force origin")
298            .expect("after revoke"));
299        assert_eq!(store.grant_count(), 0);
300    }
301
302    #[test]
303    fn clear_removes_all() {
304        let store = DefaultGrantStore::new();
305        store
306            .grant(CommandGrant::persistent("rm -rf"))
307            .expect("grant persistent");
308        store
309            .grant(CommandGrant::one_time("git push --force"))
310            .expect("grant one-time");
311        assert_eq!(store.grant_count(), 2);
312
313        store.clear().expect("clear all grants");
314        assert_eq!(store.grant_count(), 0);
315        assert!(!store
316            .is_granted("rm -rf ./temp")
317            .expect("after clear persistent"));
318        assert!(!store
319            .is_granted("git push --force origin")
320            .expect("after clear one-time"));
321    }
322
323    #[test]
324    fn mixed_grant_types() {
325        let store = DefaultGrantStore::new();
326        store
327            .grant(CommandGrant::persistent("ls"))
328            .expect("grant persistent");
329        store
330            .grant(CommandGrant::one_time("rm -rf"))
331            .expect("grant one-time");
332        assert_eq!(store.grant_count(), 2);
333
334        // Both match
335        assert!(store.is_granted("ls -la").expect("persistent match"));
336        assert!(store
337            .is_granted("rm -rf ./temp")
338            .expect("one-time consumed"));
339
340        // One-time gone, persistent remains
341        assert_eq!(store.grant_count(), 1);
342        assert!(store.is_granted("ls -la").expect("persistent still valid"));
343        assert!(!store.is_granted("rm -rf ./temp").expect("one-time gone"));
344    }
345
346    #[test]
347    fn word_boundary_pattern_match() {
348        let store = DefaultGrantStore::new();
349        store
350            .grant(CommandGrant::persistent("ls"))
351            .expect("grant persistent");
352
353        // "ls" matches exact and "ls <args>" but NOT "lsblk"
354        assert!(store.is_granted("ls").expect("exact match"));
355        assert!(store.is_granted("ls -la").expect("prefix+space match"));
356        assert!(!store.is_granted("lsblk").expect("word boundary rejection"));
357    }
358
359    #[test]
360    fn revoke_nonexistent_is_noop() {
361        let store = DefaultGrantStore::new();
362        store
363            .revoke("nonexistent")
364            .expect("revoke nonexistent noop");
365        assert_eq!(store.grant_count(), 0);
366    }
367
368    #[test]
369    fn list_grants_empty() {
370        let store = DefaultGrantStore::new();
371        assert!(store.list_grants().expect("list empty store").is_empty());
372    }
373
374    #[test]
375    fn list_grants_persistent_only() {
376        let store = DefaultGrantStore::new();
377        store
378            .grant(CommandGrant::persistent("ls"))
379            .expect("grant ls");
380        store
381            .grant(CommandGrant::persistent("cargo"))
382            .expect("grant cargo");
383
384        let grants = store.list_grants().expect("list grants");
385        assert_eq!(grants.len(), 2);
386        assert!(grants.iter().all(|g| g.kind == GrantKind::Persistent));
387
388        let patterns: HashSet<_> = grants.iter().map(|g| g.pattern.as_str()).collect();
389        assert!(patterns.contains("ls"));
390        assert!(patterns.contains("cargo"));
391    }
392
393    #[test]
394    fn list_grants_mixed() {
395        let store = DefaultGrantStore::new();
396        store
397            .grant(CommandGrant::persistent("ls"))
398            .expect("grant persistent");
399        store
400            .grant(CommandGrant::one_time("rm -rf"))
401            .expect("grant one-time");
402
403        let grants = store.list_grants().expect("list mixed grants");
404        assert_eq!(grants.len(), 2);
405
406        let persistent: Vec<_> = grants
407            .iter()
408            .filter(|g| g.kind == GrantKind::Persistent)
409            .collect();
410        let one_time: Vec<_> = grants
411            .iter()
412            .filter(|g| g.kind == GrantKind::OneTime)
413            .collect();
414        assert_eq!(persistent.len(), 1);
415        assert_eq!(one_time.len(), 1);
416        assert_eq!(persistent[0].pattern, "ls");
417        assert_eq!(one_time[0].pattern, "rm -rf");
418    }
419
420    #[test]
421    fn list_grants_roundtrip() {
422        let store = DefaultGrantStore::new();
423        store
424            .grant(CommandGrant::persistent("ls"))
425            .expect("grant ls");
426        store
427            .grant(CommandGrant::persistent("cargo"))
428            .expect("grant cargo");
429        store
430            .grant(CommandGrant::one_time("rm -rf"))
431            .expect("grant rm");
432
433        let grants = store.list_grants().expect("list grants");
434
435        // Restore into a new store via restore_grants
436        let store2 = DefaultGrantStore::new();
437        store2.restore_grants(&grants).expect("restore grants");
438
439        assert_eq!(store2.grant_count(), 3);
440        assert!(store2.is_granted("ls -la").expect("restored ls"));
441        assert!(store2.is_granted("cargo test").expect("restored cargo"));
442        assert!(store2
443            .is_granted("rm -rf ./temp")
444            .expect("restored rm consumes"));
445        assert!(!store2
446            .is_granted("rm -rf ./temp")
447            .expect("consumed rm gone"));
448    }
449
450    #[test]
451    fn restore_grants_additive() {
452        let store = DefaultGrantStore::new();
453        store
454            .grant(CommandGrant::persistent("ls"))
455            .expect("grant ls");
456
457        let additional = vec![
458            CommandGrant::persistent("cargo"),
459            CommandGrant::one_time("git push"),
460        ];
461        store.restore_grants(&additional).expect("restore additive");
462
463        assert_eq!(store.grant_count(), 3);
464        assert!(store.is_granted("ls -la").expect("original ls"));
465        assert!(store.is_granted("cargo test").expect("added cargo"));
466        assert!(store
467            .is_granted("git push origin main")
468            .expect("added git push"));
469    }
470
471    #[test]
472    fn restore_grants_serde_roundtrip() {
473        let store = DefaultGrantStore::new();
474        store
475            .grant(CommandGrant::persistent("ls"))
476            .expect("grant ls");
477        store
478            .grant(CommandGrant::persistent("cargo"))
479            .expect("grant cargo");
480        store
481            .grant(CommandGrant::one_time("rm -rf"))
482            .expect("grant rm");
483
484        let grants = store.list_grants().expect("list grants");
485        let json = serde_json::to_string(&grants).expect("serialize grants");
486        let restored: Vec<CommandGrant> = serde_json::from_str(&json).expect("deserialize grants");
487
488        let store2 = DefaultGrantStore::new();
489        store2.restore_grants(&restored).expect("restore from json");
490
491        assert_eq!(store2.grant_count(), 3);
492        assert!(store2.is_granted("ls -la").expect("serde restored ls"));
493        assert!(store2
494            .is_granted("cargo test")
495            .expect("serde restored cargo"));
496    }
497
498    #[test]
499    fn thread_safety_basic() {
500        use std::sync::Arc;
501        use std::thread;
502
503        let store = Arc::new(DefaultGrantStore::new());
504
505        let handles: Vec<_> = (0..4)
506            .map(|i| {
507                let store = Arc::clone(&store);
508                thread::spawn(move || {
509                    let pattern = format!("cmd-{i}");
510                    store
511                        .grant(CommandGrant::persistent(&pattern))
512                        .expect("concurrent grant");
513                    assert!(store
514                        .is_granted(&format!("{pattern} arg"))
515                        .expect("concurrent is_granted"));
516                })
517            })
518            .collect();
519
520        for h in handles {
521            h.join().expect("thread panicked");
522        }
523
524        assert_eq!(store.grant_count(), 4);
525    }
526}