diff --git a/crates/relicario-cli/tests/org_authz.rs b/crates/relicario-cli/tests/org_authz.rs new file mode 100644 index 0000000..0017a29 --- /dev/null +++ b/crates/relicario-cli/tests/org_authz.rs @@ -0,0 +1,215 @@ +//! Authorization regression tests for the `relicario org` item commands. +//! +//! These cover two gaps the B9–B14 item-CRUD work left open: +//! 1. Grant-DENIAL on the read/mutate-by-query commands (`get`, `edit`, `rm`, +//! `restore`, `purge`). Only `add` had a denial test before this. An +//! ungranted member must be rejected by EVERY one of them, and `get` must +//! not leak the item's plaintext. +//! 2. SecureNote body masking on `org get`, mirroring the Login-password +//! masking already asserted in `org_items.rs`. +//! +//! The multi-member harness mirrors `org_lifecycle.rs`'s `Dev` pattern: each +//! `Dev` is an isolated XDG config home carrying its own ed25519 device key, so +//! a second member can be added with their OWN keypair and then attempt commands +//! against the shared vault. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +/// A device home (its own XDG config + ed25519 signing key). One `Dev` is the +/// owner; a second `Dev` plays the ungranted member. +struct Dev { + xdg: PathBuf, + _config: TempDir, +} + +impl Dev { + /// Generate an OpenSSH ed25519 signing key for `name` and mark it current. + fn new(name: &str) -> Self { + let config = TempDir::new().unwrap(); + let xdg = config.path().to_path_buf(); + let devices = xdg.join("relicario").join("devices").join(name); + std::fs::create_dir_all(&devices).unwrap(); + let keyfile = devices.join("signing.key"); + let st = Command::new("ssh-keygen") + .args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"]) + .arg(&keyfile) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("ssh-keygen"); + assert!(st.success(), "ssh-keygen failed"); + std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap(); + std::fs::write( + xdg.join("relicario").join("devices").join("current"), + format!("{name}\n"), + ) + .unwrap(); + Dev { xdg, _config: config } + } + + /// The OpenSSH public key string for one of this device's keys. + fn pubkey(&self, name: &str) -> String { + std::fs::read_to_string( + self.xdg.join("relicario").join("devices").join(name).join("signing.pub"), + ) + .unwrap() + .trim() + .to_string() + } + + /// Run `relicario ` against `vault` with this device active. + fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.env("XDG_CONFIG_HOME", &self.xdg) + .env("RELICARIO_ORG_DIR", vault) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.output().unwrap() + } +} + +fn owner_member_id(vault: &Path) -> String { + let s = std::fs::read_to_string(vault.join("members.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + v["members"][0]["member_id"].as_str().unwrap().to_string() +} + +/// Look up a member's id by display name (used to find a freshly added member). +fn member_id_by_name(vault: &Path, name: &str) -> String { + let s = std::fs::read_to_string(vault.join("members.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + v["members"] + .as_array() + .unwrap() + .iter() + .find(|m| m["display_name"] == name) + .unwrap_or_else(|| panic!("member `{name}` not found in members.json")) + ["member_id"] + .as_str() + .unwrap() + .to_string() +} + +use assert_cmd::cargo::CommandCargoExt as _; + +#[test] +fn org_get_edit_rm_restore_purge_reject_ungranted_member() { + // Owner inits an org, creates `prod`, grants ONLY the owner, and adds an + // item into `prod`. + let owner_dev = Dev::new("owner-laptop"); + let vault_tmp = TempDir::new().unwrap(); + let vault = vault_tmp.path(); + + assert!(owner_dev + .run(vault, &["org", "init", "--dir", vault.to_str().unwrap(), "--name", "Acme"]) + .status + .success()); + let owner = owner_member_id(vault); + assert!(owner_dev.run(vault, &["org", "create-collection", "prod", "--name", "Prod"]).status.success()); + assert!(owner_dev.run(vault, &["org", "grant", &owner, "prod"]).status.success()); + assert!(owner_dev + .run(vault, &[ + "org", "add", "login", "--collection", "prod", + "--title", "GitHub", "--username", "alice", "--password", "hunter2", + ]) + .status + .success()); + + // A SECOND member joins with their OWN device key but is NOT granted `prod`. + let other_dev = Dev::new("other-laptop"); + let other_pub = other_dev.pubkey("other-laptop"); + assert!(owner_dev + .run(vault, &["org", "add-member", "--key", &other_pub, "--name", "Mallory", "--role", "member"]) + .status + .success()); + // Sanity: the member exists but holds no collection grants. + let mallory = member_id_by_name(vault, "Mallory"); + assert!(!mallory.is_empty()); + + // EVERY read/mutate-by-query command must be rejected for the ungranted + // member, and `get` must NOT print the plaintext password. + let get = other_dev.run(vault, &["org", "get", "GitHub"]); + let get_out = String::from_utf8_lossy(&get.stdout).to_string(); + let get_err = String::from_utf8_lossy(&get.stderr).to_string(); + assert!(!get.status.success(), "get must be rejected for ungranted member: {get_out}{get_err}"); + assert!(!get_out.contains("hunter2"), "get leaked plaintext to ungranted member: {get_out}"); + assert!(!get_out.contains("alice"), "get leaked username to ungranted member: {get_out}"); + assert!( + get_err.contains("no item matches") || get_err.contains("access denied"), + "get error should be denial / not-found: {get_err}" + ); + + // get --show must ALSO be denied and reveal nothing. + let get_show = other_dev.run(vault, &["org", "get", "GitHub", "--show"]); + assert!(!get_show.status.success(), "get --show must be rejected for ungranted member"); + assert!( + !String::from_utf8_lossy(&get_show.stdout).contains("hunter2"), + "get --show leaked plaintext to ungranted member" + ); + + for (label, args) in [ + ("edit", vec!["org", "edit", "GitHub", "--username", "evil"]), + ("rm", vec!["org", "rm", "GitHub"]), + ("restore", vec!["org", "restore", "GitHub"]), + ("purge", vec!["org", "purge", "GitHub"]), + ] { + let out = other_dev.run(vault, &args); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert!( + !out.status.success(), + "`org {label}` must be rejected for ungranted member; stderr: {stderr}" + ); + assert!( + stderr.contains("no item matches") || stderr.contains("access denied"), + "`org {label}` error should be denial / not-found: {stderr}" + ); + } + + // The item is untouched: the owner can still read the original password and + // the username was NOT changed to the ungranted member's "evil" attempt. + let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]); + let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string(); + assert!(owner_get.status.success(), "owner should still read the item"); + assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}"); + assert!(owner_out.contains("alice"), "edit by ungranted member must not have changed username: {owner_out}"); + assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}"); +} + +#[test] +fn org_get_masks_secure_note_body_until_show() { + let owner_dev = Dev::new("owner-laptop"); + let vault_tmp = TempDir::new().unwrap(); + let vault = vault_tmp.path(); + + assert!(owner_dev + .run(vault, &["org", "init", "--dir", vault.to_str().unwrap(), "--name", "Acme"]) + .status + .success()); + let owner = owner_member_id(vault); + assert!(owner_dev.run(vault, &["org", "create-collection", "prod", "--name", "Prod"]).status.success()); + assert!(owner_dev.run(vault, &["org", "grant", &owner, "prod"]).status.success()); + assert!(owner_dev + .run(vault, &[ + "org", "add", "secure-note", "--collection", "prod", + "--title", "Recovery", "--body", "super-secret-body", + ]) + .status + .success()); + + // Default get masks the body and never prints the plaintext. + let masked = owner_dev.run(vault, &["org", "get", "Recovery"]); + assert!(masked.status.success(), "get: {}", String::from_utf8_lossy(&masked.stderr)); + let masked_out = String::from_utf8_lossy(&masked.stdout).to_string(); + assert!(masked_out.contains("********"), "expected masked body: {masked_out}"); + assert!(!masked_out.contains("super-secret-body"), "masked get leaked the body: {masked_out}"); + + // get --show reveals the body. + let shown = owner_dev.run(vault, &["org", "get", "Recovery", "--show"]); + assert!(shown.status.success(), "get --show: {}", String::from_utf8_lossy(&shown.stderr)); + let shown_out = String::from_utf8_lossy(&shown.stdout).to_string(); + assert!(shown_out.contains("super-secret-body"), "expected plaintext body with --show: {shown_out}"); +}