390 lines
11 KiB
Rust
390 lines
11 KiB
Rust
//! Retained v2 item, grant, and transaction regressions over native SQLite.
|
|
use sausage::cred::{
|
|
edits::{Delete, Edit},
|
|
grants::Grant,
|
|
items::*,
|
|
requests::{GrantRequest, Overrides},
|
|
script::{Limits, Script},
|
|
*,
|
|
};
|
|
struct Fake;
|
|
impl Script for Fake {
|
|
fn evaluate(
|
|
&self,
|
|
_: &str,
|
|
_: &str,
|
|
_: Option<&str>,
|
|
_: Limits,
|
|
) -> std::result::Result<String, script::Error> {
|
|
Ok("true".into())
|
|
}
|
|
}
|
|
fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cred = Cred::open(dir.path()).unwrap();
|
|
let caller = Caller {
|
|
pid: 3,
|
|
program: "/bin/tool.wasm".into(),
|
|
conversations: vec!["root".into()],
|
|
};
|
|
let item = cred
|
|
.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![
|
|
Field {
|
|
field_id: String::new(),
|
|
name: "first".into(),
|
|
public: false,
|
|
value: None,
|
|
needs_value: true,
|
|
field_type: types::FieldType::Text,
|
|
key_type: None,
|
|
},
|
|
Field {
|
|
field_id: String::new(),
|
|
name: "second".into(),
|
|
public: false,
|
|
value: None,
|
|
needs_value: true,
|
|
field_type: types::FieldType::Text,
|
|
key_type: None,
|
|
},
|
|
],
|
|
},
|
|
&caller,
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
(dir, cred, caller, item)
|
|
}
|
|
fn parts() -> http::request::Parts {
|
|
http::Request::get("https://api.test/")
|
|
.body(())
|
|
.unwrap()
|
|
.into_parts()
|
|
.0
|
|
}
|
|
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: None,
|
|
}
|
|
}
|
|
#[test]
|
|
fn missing_values_and_wrong_master_spend_nothing() {
|
|
let (dir, cred, caller, item) = fixture();
|
|
let grant = cred
|
|
.grant_create(grant(&item), &caller, cred.unlocked().unwrap())
|
|
.unwrap();
|
|
assert_eq!(
|
|
cred.inject(parts(), &caller, &Fake).err().unwrap().0,
|
|
"needs_value"
|
|
);
|
|
assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0);
|
|
for field in &item.fields {
|
|
cred.set_field(
|
|
&item.id,
|
|
&field.field_id,
|
|
"opaque",
|
|
&caller,
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
}
|
|
drop(cred);
|
|
std::fs::write(dir.path().join("cred.key"), [7u8; 32]).unwrap();
|
|
assert!(Cred::open(dir.path()).is_err());
|
|
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
|
|
let uses: i64 = db
|
|
.query_row("SELECT uses FROM grants WHERE id=?", [&grant.id], |r| {
|
|
r.get(0)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(uses, 0);
|
|
}
|
|
#[test]
|
|
fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() {
|
|
let (dir, cred, caller, item) = fixture();
|
|
for field in &item.fields {
|
|
cred.set_field(
|
|
&item.id,
|
|
&field.field_id,
|
|
"opaque",
|
|
&caller,
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
}
|
|
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
|
|
let cipher = || {
|
|
let mut q = db
|
|
.prepare("SELECT ciphertext FROM fields ORDER BY id")
|
|
.unwrap();
|
|
q.query_map([], |r| r.get::<_, Vec<u8>>(0))
|
|
.unwrap()
|
|
.collect::<rusqlite::Result<Vec<_>>>()
|
|
.unwrap()
|
|
};
|
|
let before = cipher();
|
|
let old = cred.item_get(&item.id).unwrap();
|
|
let mut config = old.clone();
|
|
config.fields[0].name = "second".into();
|
|
config.fields[1].name = "first".into();
|
|
let id = cred
|
|
.file_edit_request(
|
|
&caller,
|
|
"swap",
|
|
Edit {
|
|
item: item.id.clone(),
|
|
base_version: old.version,
|
|
config,
|
|
},
|
|
)
|
|
.unwrap();
|
|
cred.approve(
|
|
&id,
|
|
sausage::cred::webauthn::Decision::Approve,
|
|
Overrides::default(),
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert!(cipher() == before);
|
|
let current = cred.item_get(&item.id).unwrap();
|
|
let mut config = current.clone();
|
|
config.fields.clear();
|
|
let id = cred
|
|
.file_edit_request(
|
|
&caller,
|
|
"remove",
|
|
Edit {
|
|
item: item.id.clone(),
|
|
base_version: current.version,
|
|
config,
|
|
},
|
|
)
|
|
.unwrap();
|
|
let history = cred.item_versions(&item.id).unwrap().len();
|
|
let audit = cred.audit_list().unwrap().len();
|
|
db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT,'forced'); END;").unwrap();
|
|
assert!(
|
|
cred.approve(
|
|
&id,
|
|
sausage::cred::webauthn::Decision::Approve,
|
|
Overrides::default(),
|
|
cred.unlocked().unwrap()
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(cipher() == before);
|
|
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
|
|
assert_eq!(cred.item_versions(&item.id).unwrap().len(), history);
|
|
assert_eq!(cred.audit_list().unwrap().len(), audit);
|
|
}
|
|
#[test]
|
|
fn deleted_item_revert_preserves_identity_but_never_restores_secrets() {
|
|
let (_dir, cred, caller, item) = fixture();
|
|
for field in &item.fields {
|
|
cred.set_field(
|
|
&item.id,
|
|
&field.field_id,
|
|
"opaque",
|
|
&caller,
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
}
|
|
let current = cred.item_get(&item.id).unwrap();
|
|
let id = cred
|
|
.file_delete_request(
|
|
&caller,
|
|
"delete",
|
|
Delete {
|
|
item: item.id.clone(),
|
|
base_version: current.version,
|
|
},
|
|
)
|
|
.unwrap();
|
|
cred.approve(
|
|
&id,
|
|
sausage::cred::webauthn::Decision::Approve,
|
|
Overrides::default(),
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
let id = cred
|
|
.file_revert_request(
|
|
&caller,
|
|
"restore",
|
|
&item.id,
|
|
current.version + 1,
|
|
current.version,
|
|
)
|
|
.unwrap();
|
|
cred.approve(
|
|
&id,
|
|
sausage::cred::webauthn::Decision::Approve,
|
|
Overrides::default(),
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
let restored = cred.item_get(&item.id).unwrap();
|
|
assert_eq!(restored.id, item.id);
|
|
assert_eq!(restored.version, current.version + 2);
|
|
assert!(
|
|
restored
|
|
.fields
|
|
.iter()
|
|
.all(|f| f.needs_value && f.value.is_none())
|
|
);
|
|
assert_eq!(restored.fields[0].field_id, item.fields[0].field_id);
|
|
}
|
|
#[test]
|
|
fn secret_entry_keeps_oauth_cardinality_and_rolls_back_ciphertext() {
|
|
let (dir, cred, caller, item) = fixture();
|
|
let input = FieldInput {
|
|
value: sausage::cred::items::FieldValue::Plain("opaque"),
|
|
field_type: Some(types::FieldType::Oauth2RefreshToken),
|
|
key_type: None,
|
|
};
|
|
cred.set_field_input(
|
|
&item.id,
|
|
&item.fields[0].field_id,
|
|
&input,
|
|
&caller,
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
cred.set_field(
|
|
&item.id,
|
|
&item.fields[1].field_id,
|
|
"opaque",
|
|
&caller,
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
let current = cred.item_get(&item.id).unwrap();
|
|
let history = cred.item_versions(&item.id).unwrap().len();
|
|
let audit = cred.audit_list().unwrap().len();
|
|
let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap();
|
|
let cipher = || {
|
|
db.query_row(
|
|
"SELECT ciphertext FROM fields WHERE item=?1 AND id=?2",
|
|
rusqlite::params![item.id, item.fields[1].field_id],
|
|
|r| r.get::<_, Vec<u8>>(0),
|
|
)
|
|
.unwrap()
|
|
};
|
|
let before = cipher();
|
|
assert_eq!(
|
|
cred.set_field_input(
|
|
&item.id,
|
|
&item.fields[1].field_id,
|
|
&input,
|
|
&caller,
|
|
cred.unlocked().unwrap()
|
|
)
|
|
.err()
|
|
.unwrap()
|
|
.0,
|
|
"invalid_params"
|
|
);
|
|
assert!(cipher() == before);
|
|
assert_eq!(cred.item_get(&item.id).unwrap().version, current.version);
|
|
assert_eq!(cred.item_versions(&item.id).unwrap().len(), history);
|
|
assert_eq!(cred.audit_list().unwrap().len(), audit);
|
|
}
|
|
#[test]
|
|
fn typed_secret_entry_and_bundle_normalization() {
|
|
let (_dir, cred, caller, item) = fixture();
|
|
let mut metadata = item.clone();
|
|
metadata.fields[0].field_type = types::FieldType::SshPrivateKey;
|
|
metadata.fields[0].key_type = Some("es256".into());
|
|
let id = cred
|
|
.file_edit_request(
|
|
&caller,
|
|
"metadata",
|
|
Edit {
|
|
item: item.id.clone(),
|
|
base_version: item.version,
|
|
config: metadata,
|
|
},
|
|
)
|
|
.unwrap();
|
|
cred.approve(
|
|
&id,
|
|
sausage::cred::webauthn::Decision::Approve,
|
|
Overrides::default(),
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
let input = FieldInput {
|
|
value: sausage::cred::items::FieldValue::Plain("two\nlines"),
|
|
field_type: Some(types::FieldType::Note),
|
|
key_type: None,
|
|
};
|
|
cred.set_field_input(
|
|
&item.id,
|
|
&item.fields[0].field_id,
|
|
&input,
|
|
&caller,
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
let current = cred.item_get(&item.id).unwrap();
|
|
assert_eq!(current.fields[0].field_type, types::FieldType::Note);
|
|
assert!(current.fields[0].key_type.is_none());
|
|
assert!(current.fields[0].value.is_none());
|
|
assert_eq!(
|
|
cred.item_versions(&item.id).unwrap()[0].config.fields[0].field_type,
|
|
types::FieldType::Text
|
|
);
|
|
let p = GrantRequest {
|
|
items: vec![item.name.clone(), item.id.clone()],
|
|
duration_secs: None,
|
|
max_uses: Some(1),
|
|
validator: None,
|
|
};
|
|
let id = cred.file_request(&caller, "bundle", p).unwrap();
|
|
let p = GrantRequest {
|
|
items: vec![item.id],
|
|
duration_secs: None,
|
|
max_uses: Some(1),
|
|
validator: None,
|
|
};
|
|
assert_eq!(cred.file_request(&caller, "bundle", p).unwrap(), id);
|
|
cred.approve(
|
|
&id,
|
|
sausage::cred::webauthn::Decision::Approve,
|
|
Overrides {
|
|
unlimited: true,
|
|
..Overrides::default()
|
|
},
|
|
cred.unlocked().unwrap(),
|
|
)
|
|
.unwrap();
|
|
let g = cred.grant_list().unwrap().remove(0);
|
|
assert_eq!(g.items.len(), 1);
|
|
assert!(g.max_uses.is_none());
|
|
assert!(g.exp.is_some());
|
|
}
|