Files
relicario/crates/relicario-cli/tests/org_authz.rs
adlee-was-taken 04ad98973a test(cli/org): adapt grant-denial edit case to interactive org edit
B3 dropped the flat --username/--url/... flags from `org edit`, so the
ungranted-member denial test must drive the bare interactive form. The
ungranted member is now rejected at manifest lookup (filter_for_member +
resolve_org_query) before any prompt is read.
2026-06-20 20:49:12 -04:00

217 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 [
// `org edit` is now interactive (no flat flags); the ungranted member is
// rejected at manifest lookup, before any prompt is read.
("edit", vec!["org", "edit", "GitHub"]),
("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
// username — the ungranted member's get/edit/rm/restore/purge were all denied.
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"), "ungranted member must not have modified the item: {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}");
}