Files
sausage/host/tests/cred_grants.rs
T

209 lines
6.3 KiB
Rust

//! 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<String, script::Error> {
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())
);
}