//! Grant expiry, revocation, validators and issuance boundaries from v2. use sausage::cred::{ grants::Grant, inject::Outcome, items::*, script::{Limits, Script}, *, }; struct Fake; impl Script for Fake { fn evaluate( &self, source: &str, input: &str, _: Option<&str>, _: Limits, ) -> std::result::Result { let ctx: serde_json::Value = serde_json::from_str(input).unwrap(); assert_eq!(ctx["grant"]["conversation"], "root"); Ok(if source == "deny" { "false" } else { "true" }.into()) } } fn parts() -> http::request::Parts { http::Request::get("https://api.test/path") .body(()) .unwrap() .into_parts() .0 } fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { let dir = tempfile::tempdir().unwrap(); let c = Cred::open(dir.path()).unwrap(); let caller = Caller { program: "/bin/tool.wasm".into(), pid: 1, conversations: vec!["root".into()], }; let i = c .create_item( Item { id: String::new(), name: "item".into(), version: 0, free_use: false, rules: vec![Rule { port: None, scheme: "https".into(), hosts: vec!["api.test".into()], path_prefixes: vec!["/".into()], methods: None, }], validate_js: None, transform_js: String::new(), fields: vec![], }, &caller, c.unlocked().unwrap(), ) .unwrap(); (dir, c, caller, i) } fn grant(item: &Item) -> Grant { Grant { id: String::new(), items: vec![item.id.clone()], subject: types::Subject::Conversation("root".into()), exp: None, max_uses: Some(1), uses: 0, revoked: false, validator: None, note: Some("work".into()), } } #[test] fn program_grant_covers_service_and_agent_but_not_another_program() { let (_dir, c, _caller, _item) = fixture(); let service = Caller { pid: 10, program: "/bin/service.wasm".into(), conversations: vec![], }; let Outcome::Denied { request, .. } = c.inject(parts(), &service, &Fake).unwrap() else { panic!("expected request") }; assert_eq!( c.request_list().unwrap()[0].subject, types::Subject::Program(service.program.clone()) ); c.approve( &request, sausage::cred::webauthn::Decision::Approve, sausage::cred::requests::Overrides::default(), c.unlocked().unwrap(), ) .unwrap(); let agent = Caller { pid: 11, program: service.program.clone(), conversations: vec!["agent".into()], }; for caller in [&service, &agent] { assert!(matches!( c.inject(parts(), caller, &Fake).unwrap(), Outcome::Forward(_) )); } let other = Caller { pid: 12, program: "/bin/other.wasm".into(), conversations: vec![], }; assert!(matches!( c.inject(parts(), &other, &Fake).unwrap(), Outcome::Denied { .. } )); let decisions = c.audit_list().unwrap(); let row = decisions.iter().find(|r| r.event == "approved").unwrap(); assert!(row.approver.is_none()); assert_eq!(row.program, service.program); assert!(row.conversation.is_none()); } #[test] fn expiry_boundary_revoke_and_exhaustion() { let (dir, c, caller, i) = fixture(); let g = c .grant_create(grant(&i), &caller, c.unlocked().unwrap()) .unwrap(); assert!(matches!( c.inject(parts(), &caller, &Fake).unwrap(), Outcome::Forward(_) )); assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); assert!(matches!( c.inject(parts(), &caller, &Fake).unwrap(), Outcome::Denied { .. } )); assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); let mut unlimited = grant(&i); unlimited.max_uses = None; let g = c .grant_create(unlimited, &caller, c.unlocked().unwrap()) .unwrap(); let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); db.execute("UPDATE grants SET exp=0 WHERE id=?", [&g.id]) .unwrap(); assert!(matches!( c.inject(parts(), &caller, &Fake).unwrap(), Outcome::Denied { .. } )); assert_eq!(c.grant_status(&g.id).unwrap().uses, 0); c.revoke_subjects(&caller.conversations, &caller, c.unlocked().unwrap()) .unwrap(); assert!(c.grant_list().unwrap().iter().all(|g| g.revoked)); } #[test] fn reject_bad_issuance_without_rows() { let (_dir, c, caller, i) = fixture(); let mut g = grant(&i); g.max_uses = Some(0); assert_eq!( c.grant_create(g, &caller, c.unlocked().unwrap()) .unwrap_err() .0, "invalid_uses" ); let mut g = grant(&i); g.subject = types::Subject::Conversation(String::new()); assert_eq!( c.grant_create(g, &caller, c.unlocked().unwrap()) .unwrap_err() .0, "invalid_subject" ); assert!(c.grant_list().unwrap().is_empty()); assert_eq!(c.audit_list().unwrap().len(), 1); } #[test] fn preflight_spends_nothing_and_validator_denial_spends_one() { let (_dir, c, caller, i) = fixture(); let mut g = grant(&i); g.validator = Some("deny".into()); let g = c.grant_create(g, &caller, c.unlocked().unwrap()).unwrap(); let mut p = parts(); p.headers .insert("bad", http::HeaderValue::from_bytes(&[0xff]).unwrap()); assert_eq!( c.inject(p, &caller, &Fake).err().unwrap().0, "invalid_headers" ); assert_eq!(c.grant_status(&g.id).unwrap().uses, 0); assert_eq!( c.inject(parts(), &caller, &Fake).err().unwrap().0, "validator_denied" ); assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); let audit = c.audit_list().unwrap(); let use_row = audit.last().unwrap(); assert_eq!(use_row.event, "fetch.denied"); assert_eq!(use_row.pid, caller.pid); assert_eq!(use_row.conversation.as_deref(), Some("root")); assert!( audit .iter() .filter(|r| r.event == "grant.create") .all(|r| r.approver.is_none()) ); }