test(cli/org): add grant-denial + secure-note masking regression tests
Cover two authz gaps left by the B9-B14 org item-CRUD work: 1. Grant-DENIAL on the read/mutate-by-query commands. A second member added with their own device key but NOT granted `prod` is rejected by every one of `org get`, `edit`, `rm`, `restore`, and `purge`, and `org get` (with and without --show) leaks no plaintext. Previously only `org add` had a denial test. Also asserts the item is untouched afterward (owner still reads the original password/username). 2. SecureNote body masking: `org get <note>` prints `********` and not the body; `org get <note> --show` reveals it. Mirrors the existing Login-password masking assertions in org_items.rs. New tests/org_authz.rs reuses the multi-member `Dev` harness pattern from org_lifecycle.rs (one XDG config home + ed25519 device key per member), so a second member joins with their own keypair. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXpTHcQzw1n8qjYwZqruzQ
This commit is contained in:
215
crates/relicario-cli/tests/org_authz.rs
Normal file
215
crates/relicario-cli/tests/org_authz.rs
Normal file
@@ -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 <args>` 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}");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user