Files
relicario/crates/relicario-cli/tests/org_authz.rs
adlee-was-taken 44d61ae7a7 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
2026-06-20 15:55:25 -04:00

216 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Authorization regression tests for the `relicario org` item commands.
//!
//! These cover two gaps the B9B14 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}");
}