orcs_runtime/auth/
grant_store.rs1use orcs_auth::{CommandGrant, GrantError, GrantKind, GrantPolicy};
20use std::collections::HashSet;
21use std::sync::RwLock;
22
23#[derive(Debug, Default)]
61pub struct DefaultGrantStore {
62 inner: RwLock<GrantSets>,
64}
65
66#[derive(Debug, Default)]
68struct GrantSets {
69 persistent: HashSet<String>,
71 one_time: HashSet<String>,
73}
74
75impl DefaultGrantStore {
76 #[must_use]
78 pub fn new() -> Self {
79 Self::default()
80 }
81
82 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 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 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 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 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
208fn 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 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 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 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 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}