209 lines
6.3 KiB
Rust
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())
|
|
);
|
|
}
|