use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signer, SigningKey};
use lys_pass::deliberate::CheckAnswer;
use lys_pass::drafts::DraftRequest;
use lys_pass::{Error, KeySet, Mode, Target, conformance};
use serde_json::{Value, json};
use super::{AUDIENCE, LiveCheck, PASS_MISSING, Trust, Unreachable, Verdict, decide, target};
use crate::serve::route_matrix::{Class, route};
type TestResult = Result<(), Box<dyn std::error::Error>>;
pub(super) const ISSUER: &str = "https://lys.example";
pub(super) const NOW: u64 = 1_000;
pub(super) const KID: &str = "haematite-test";
pub(super) fn signer() -> SigningKey {
SigningKey::from_bytes(&[7; 32])
}
pub(super) fn keys() -> Result<KeySet, Error> {
let x = URL_SAFE_NO_PAD.encode(signer().verifying_key().as_bytes());
KeySet::from_json(
&json!({"keys": [{"kty": "OKP", "crv": "Ed25519", "kid": KID, "x": x, "alg": "EdDSA", "use": "sig"}]})
.to_string(),
)
}
pub(super) fn pass(holder: &str, rights: &Value) -> String {
let header =
URL_SAFE_NO_PAD.encode(json!({"alg": "EdDSA", "typ": "JWT", "kid": KID}).to_string());
let claims = URL_SAFE_NO_PAD.encode(
json!({
"iss": ISSUER, "sub": holder, "aud": AUDIENCE, "iat": NOW - 10, "exp": NOW + 600,
"holder": {"id": holder, "kind": "person", "responsible": null},
"rights": rights,
})
.to_string(),
);
let signature = URL_SAFE_NO_PAD.encode(
signer()
.sign(format!("{header}.{claims}").as_bytes())
.to_bytes(),
);
format!("{header}.{claims}.{signature}")
}
pub(super) fn role(actions: &[&str], grant: &str) -> Value {
reaching(actions, grant, &["main"])
}
pub(super) fn reaching(actions: &[&str], grant: &str, branches: &[&str]) -> Value {
let mut rights = vec![
json!({"resource": {"kind": "haematite.database", "id": "wards"}, "actions": actions, "mode": "outright", "grant": grant}),
json!({"resource": {"kind": "haematite.collection", "id": "wards.patients"}, "actions": actions, "mode": "outright", "grant": grant}),
];
rights.extend(branches.iter().map(|branch| {
json!({"resource": {"kind": "haematite.branch", "id": format!("wards.{branch}")}, "actions": actions, "mode": "outright", "grant": grant})
}));
Value::Array(rights)
}
fn ask(wire: &str, name: Option<&str>) -> Result<(Target, Class), Box<dyn std::error::Error>> {
let row = route(wire).ok_or_else(|| format!("no row for {wire}"))?;
Ok((target(row, "wards", name)?, row.class))
}
struct Grants(&'static str);
impl LiveCheck for Grants {
fn ask(&self, _token: &str, _holder: &str, _target: &Target) -> Result<CheckAnswer, Error> {
Ok(serde_json::from_value(json!({
"allowed": true, "grant": self.0, "mode": "outright", "path": [],
"via": null, "refusal": null, "reason": null, "degraded": null,
}))?)
}
fn draft(
&self,
_token: &str,
_request: &DraftRequest,
_judgment: &CheckAnswer,
) -> Result<String, Error> {
Err(Error::CannotAsk("this double records no draft"))
}
}
struct HeldByDraft;
impl LiveCheck for HeldByDraft {
fn ask(&self, _token: &str, _holder: &str, _target: &Target) -> Result<CheckAnswer, Error> {
Ok(serde_json::from_value(json!({
"allowed": false, "grant": "restore-by-draft", "mode": "by_draft", "path": [],
"via": null, "refusal": null, "reason": null, "degraded": null,
}))?)
}
fn draft(
&self,
_token: &str,
_request: &DraftRequest,
_judgment: &CheckAnswer,
) -> Result<String, Error> {
Err(Error::CannotAsk("this double records no draft"))
}
}
#[test]
fn a_collection_is_named_under_its_database() -> TestResult {
let (patients, _) = ask("documents.get", Some("patients"))?;
assert_eq!(
(
patients.kind.as_str(),
patients.id.as_str(),
patients.action.as_str()
),
("haematite.collection", "wards.patients", "read")
);
let (wards, _) = ask("store.stats", None)?;
assert_eq!(
(wards.kind.as_str(), wards.id.as_str()),
("haematite.database", "wards")
);
assert!(
ask("documents.get", None).is_err(),
"a collection route needs its collection"
);
Ok(())
}
#[test]
fn an_observer_pass_reads_and_cannot_write() -> TestResult {
let keys = keys()?;
let trust = Trust {
keys: &keys,
issuer: ISSUER,
audience: AUDIENCE,
};
let token = pass("observer", &role(&["read", "query"], "observer-grant"));
let (read, class) = ask("documents.get", Some("patients"))?;
assert_eq!(
decide(trust, Some(&token), &read, class, &Unreachable, NOW),
Verdict::Allowed {
grant: "observer-grant".to_owned()
}
);
let (write, class) = ask("document.put", Some("patients"))?;
let verdict = decide(trust, Some(&token), &write, class, &Unreachable, NOW);
let Verdict::Refused(refusal) = &verdict else {
return Err(format!("an observer write was not refused: {verdict:?}").into());
};
assert_eq!(
serde_json::to_value(refusal)?,
json!({"refused": {
"resource": "haematite.collection:wards.patients", "action": "write",
"needed": {"app": "haematite", "kind": "haematite.collection", "id": "wards.patients", "action": "write"},
"request": "https://lys.example/#/requests",
}})
);
Ok(())
}
#[test]
fn a_writer_pass_writes_and_receipts_its_grant() -> TestResult {
let keys = keys()?;
let trust = Trust {
keys: &keys,
issuer: ISSUER,
audience: AUDIENCE,
};
let token = pass("writer", &role(&["read", "query", "write"], "writer-grant"));
let (write, class) = ask("document.put", Some("patients"))?;
assert_eq!(
decide(trust, Some(&token), &write, class, &Unreachable, NOW),
Verdict::Allowed {
grant: "writer-grant".to_owned()
}
);
Ok(())
}
#[test]
fn a_deliberate_act_with_lys_unreachable_is_refused_even_with_the_right_in_the_pass() -> TestResult
{
let keys = keys()?;
let trust = Trust {
keys: &keys,
issuer: ISSUER,
audience: AUDIENCE,
};
let token = pass("administrator", &role(&["read", "branch"], "admin-grant"));
let (fork, class) = ask("branch.fork", None)?;
assert_eq!(class, Class::Deliberate);
assert_eq!(
decide(trust, Some(&token), &fork, class, &Unreachable, NOW),
Verdict::Named("lys_could_not_be_asked".to_owned())
);
assert_eq!(
decide(
trust,
Some(&token),
&fork,
class,
&Grants("live-grant"),
NOW
),
Verdict::Allowed {
grant: "live-grant".to_owned()
},
"the live answer's grant, not the pass's, receipts a deliberate act"
);
Ok(())
}
#[test]
fn a_deliberate_act_held_by_draft_is_held() -> TestResult {
let keys = keys()?;
let trust = Trust {
keys: &keys,
issuer: ISSUER,
audience: AUDIENCE,
};
let token = pass("clinician", &role(&["read"], "read-grant"));
let (fork, class) = ask("branch.fork", None)?;
assert_eq!(
decide(trust, Some(&token), &fork, class, &HeldByDraft, NOW),
Verdict::Held {
grant: "restore-by-draft".to_owned(),
mode: Mode::ByDraft,
judgment: Box::new(HeldByDraft.ask("", "", &fork)?),
}
);
Ok(())
}
#[test]
fn a_connection_without_a_pass_is_refused_by_name() -> TestResult {
let keys = keys()?;
let trust = Trust {
keys: &keys,
issuer: ISSUER,
audience: AUDIENCE,
};
let (read, class) = ask("documents.get", Some("patients"))?;
assert_eq!(
decide(trust, None, &read, class, &Unreachable, NOW),
Verdict::Named(PASS_MISSING.to_owned())
);
Ok(())
}
#[test]
fn the_shared_lys_pass_vectors_conform() -> TestResult {
let keys = KeySet::from_json(conformance::KEYS)?;
let trust = Trust {
keys: &keys,
issuer: conformance::ISSUER,
audience: conformance::AUDIENCE,
};
let valid = conformance::cases()
.into_iter()
.find(|case| case.name == "valid")
.and_then(|case| case.token)
.ok_or("the vectors carry no valid pass")?;
let report = conformance::run(|case| -> Result<conformance::Outcome, Error> {
let target = Target::new(case.kind, case.id, case.action)?;
let (token, class) = case
.token
.map_or((valid, Class::Deliberate), |token| (token, Class::Hot));
Ok(
match decide(
trust,
Some(token),
&target,
class,
&Unreachable,
conformance::NOW,
) {
Verdict::Allowed { grant } => conformance::Outcome::Allowed(grant),
Verdict::Named(name) => conformance::Outcome::NamedRefusal(name),
Verdict::Refused(refusal) => {
conformance::Outcome::PermissionRefusal(serde_json::to_string(&refusal)?)
}
Verdict::Held { grant, .. } => {
conformance::Outcome::NamedRefusal(format!("held:{grant}"))
}
},
)
});
assert!(
report.failures.is_empty(),
"nonconformant vectors: {:?}",
report.failures
);
report.require_conformant()?;
Ok(())
}
fn gate() -> Result<super::Gate, Error> {
gate_asking(std::sync::Arc::new(Unreachable))
}
fn gate_asking(live: std::sync::Arc<dyn LiveCheck + Send + Sync>) -> Result<super::Gate, Error> {
let x = URL_SAFE_NO_PAD.encode(signer().verifying_key().as_bytes());
let keys = json!({"keys": [{"kty": "OKP", "crv": "Ed25519", "kid": KID, "x": x, "alg": "EdDSA", "use": "sig"}]});
super::Gate::new(&keys.to_string(), ISSUER, "wards", live)
}
fn request(
method: &str,
params: &Value,
) -> Result<crate::serve::protocol::Operation, serde_json::Error> {
serde_json::from_str(&json!({"method": method, "params": params}).to_string())
}
fn put_patient() -> Result<crate::serve::protocol::Operation, serde_json::Error> {
request(
"mutation.apply",
&json!({
"request_id": "r", "branch": "main",
"expected": {"generation": "1", "sequence": "1"},
"change": {"kind": "document.put", "value": {"collection": "patients", "document_id": "a", "document": {}}},
}),
)
}
#[test]
fn the_gate_refuses_an_observer_write_and_allows_a_writer() -> TestResult {
let gate = gate()?;
let put = put_patient()?;
let observer = pass("observer", &role(&["read", "query"], "observer-grant"));
let refused = gate
.authorize(Some(&observer), &put, None, NOW)
.err()
.ok_or("an observer write was allowed")?;
assert_eq!(refused.code, "permission_refused");
let shape: Value = serde_json::from_str(&refused.message)?;
assert_eq!(shape["refused"]["needed"]["id"], "wards.patients");
let writer = pass("writer", &role(&["read", "query", "write"], "writer-grant"));
assert_eq!(
gate.authorize(Some(&writer), &put, None, NOW)?,
vec!["writer-grant".to_owned(), "writer-grant".to_owned()]
);
Ok(())
}
#[test]
fn the_gate_refuses_a_request_without_a_pass_and_a_deliberate_one_without_lys() -> TestResult {
let gate = gate()?;
let put = put_patient()?;
let missing = gate
.authorize(None, &put, None, NOW)
.err()
.ok_or("no pass was allowed")?;
assert_eq!(missing.code, PASS_MISSING);
let fork = request(
"mutation.apply",
&json!({
"request_id": "f", "branch": "main",
"expected": {"generation": "1", "sequence": "1"},
"change": {"kind": "branch.fork", "value": {"name": "trial", "from": {"generation": "1", "sequence": "1"}}},
}),
)?;
let admin = pass(
"administrator",
&role(&["read", "write", "branch"], "admin-grant"),
);
let unasked = gate
.authorize(Some(&admin), &fork, None, NOW)
.err()
.ok_or("a deliberate act ran without Lys")?;
assert_eq!(
(unasked.code.as_str(), unasked.message.as_str()),
("lys_could_not_be_asked", "Lys could not be asked")
);
Ok(())
}
#[test]
fn a_presented_pass_answers_its_holder_and_a_tampered_one_is_refused() -> TestResult {
let gate = gate()?;
let token = pass("clinician", &role(&["read"], "read-grant"));
assert_eq!(
gate.present(&token, NOW)?,
json!({"holder": "clinician", "expires": NOW + 600})
);
let mut tampered = token.into_bytes();
let last = tampered.len() - 2;
tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(tampered)?;
let refused = gate
.present(&tampered, NOW)
.err()
.ok_or("a tampered pass verified")?;
assert_eq!(refused.code, "signature_refused");
Ok(())
}
#[test]
fn a_branch_made_after_the_pass_is_asked_of_lys_live() -> TestResult {
let gate = gate_asking(std::sync::Arc::new(Grants("live-grant")))?;
let reader = pass("reader", &role(&["read", "query", "write"], "writer-grant"));
let review = |branch: &str| {
request(
"branch.review",
&json!({"branch": branch, "after": null, "limit": 10}),
)
};
let refused = gate
.authorize(Some(&reader), &review("import-a")?, None, NOW)
.err()
.ok_or("a branch never made here was allowed")?;
assert_eq!(refused.code, "permission_refused");
gate.placed("haematite.branch", "import-old", NOW - 20, false);
let refused = gate
.authorize(Some(&reader), &review("import-old")?, None, NOW)
.err()
.ok_or("a branch made before the pass was allowed")?;
assert_eq!(refused.code, "permission_refused");
gate.placed("haematite.branch", "import-a", NOW - 5, false);
assert_eq!(
gate.authorize(Some(&reader), &review("import-a")?, None, NOW)?,
vec!["live-grant".to_owned()]
);
Ok(())
}
#[test]
fn a_branch_made_after_the_pass_that_lys_cannot_be_asked_of_stays_refused() -> TestResult {
let gate = gate()?;
let reader = pass("reader", &role(&["read"], "reader-grant"));
gate.placed("haematite.branch", "import-a", NOW, false);
let refused = gate
.authorize(
Some(&reader),
&request(
"branch.review",
&json!({"branch": "import-a", "after": null, "limit": 10}),
)?,
None,
NOW,
)
.err()
.ok_or("allowed without Lys")?;
assert_eq!(refused.code, "permission_refused");
Ok(())
}
struct PlacedOnly(
std::sync::Arc<std::sync::atomic::AtomicBool>,
std::sync::Mutex<std::sync::mpsc::Sender<()>>,
);
impl LiveCheck for PlacedOnly {
fn ask(&self, token: &str, holder: &str, target: &Target) -> Result<CheckAnswer, Error> {
let answer = if self.0.load(std::sync::atomic::Ordering::SeqCst) {
Grants("live-grant").ask(token, holder, target)
} else {
Ok(serde_json::from_value(json!({
"allowed": false, "grant": null, "mode": null, "path": [],
"via": null, "refusal": null, "reason": null, "degraded": null,
}))?)
};
let _told = self.1.lock().map(|asked| asked.send(()));
answer
}
fn draft(
&self,
_token: &str,
_request: &DraftRequest,
_judgment: &CheckAnswer,
) -> Result<String, Error> {
Err(Error::CannotAsk("this double records no draft"))
}
}
#[test]
fn a_branch_still_being_placed_is_asked_of_lys_once_its_placement_lands() -> TestResult {
let in_lys = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let (asked, asks) = std::sync::mpsc::channel();
let gate = gate_asking(std::sync::Arc::new(PlacedOnly(
std::sync::Arc::clone(&in_lys),
std::sync::Mutex::new(asked),
)))?;
let reader = pass("reader", &role(&["read"], "reader-grant"));
gate.placed("haematite.branch", "import-a", NOW, true);
let made = gate.made();
let placer = std::thread::spawn(move || {
made.await_waiting();
let _asked_first = asks.recv_timeout(std::time::Duration::from_millis(200));
in_lys.store(true, std::sync::atomic::Ordering::SeqCst);
made.landed("haematite.branch", "import-a");
});
let review = request(
"branch.review",
&json!({"branch": "import-a", "after": null, "limit": 10}),
)?;
let allowed = gate.authorize(Some(&reader), &review, None, NOW);
placer.join().map_err(|_| "the placer panicked")?;
assert_eq!(allowed?, vec!["live-grant".to_owned()]);
Ok(())
}
fn put_on(branch: &str) -> Result<crate::serve::protocol::Operation, serde_json::Error> {
request(
"mutation.apply",
&json!({
"request_id": "r", "branch": branch,
"expected": {"generation": "1", "sequence": "1"},
"change": {"kind": "document.put", "value": {"collection": "patients", "document_id": "a", "document": {}}},
}),
)
}
#[test]
fn a_writer_writes_on_an_inherited_branch_and_is_refused_on_a_restricted_one() -> TestResult {
let gate = gate()?;
let writer = pass(
"writer",
&reaching(
&["read", "query", "write"],
"writer-grant",
&["main", "feature"],
),
);
assert_eq!(
gate.authorize(Some(&writer), &put_on("feature")?, None, NOW)?,
vec!["writer-grant".to_owned(), "writer-grant".to_owned()]
);
let refused = gate
.authorize(Some(&writer), &put_on("restricted")?, None, NOW)
.err()
.ok_or("a writer wrote on a restricted branch")?;
assert_eq!(refused.code, "permission_refused");
let shape: Value = serde_json::from_str(&refused.message)?;
assert_eq!(shape["refused"]["needed"]["kind"], "haematite.branch");
assert_eq!(shape["refused"]["needed"]["id"], "wards.restricted");
assert_eq!(shape["refused"]["needed"]["action"], "write");
let administrator = pass(
"administrator",
&reaching(
&["read", "query", "write"],
"administrator-grant",
&["main", "feature", "restricted"],
),
);
assert_eq!(
gate.authorize(Some(&administrator), &put_on("restricted")?, None, NOW)?,
vec![
"administrator-grant".to_owned(),
"administrator-grant".to_owned()
]
);
Ok(())
}