Compare commits

...

8 Commits

Author SHA1 Message Date
adlee-was-taken
e76d7167d6 test(cli/org): grant enforcement + body/secret-stdin + key-edit coverage
Closes the minor coverage gaps from the final adversarial review:
- org add card/key/totp reject ungranted + unknown collections (pins the
  grant gate on the new write paths, which runs before any secret prompt)
- secure-note --body-stdin masks body; totp --secret-stdin round-trips
  (completes the --*-stdin matrix for the org surface)
- key-material edit accept-branch round-trip, verified via get --show
2026-06-20 20:58:26 -04:00
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
adlee-was-taken
290bc4e2d0 feat(cli/org): interactive per-type org edit via shared edit helpers 2026-06-20 20:43:03 -04:00
adlee-was-taken
82feb49ab4 feat(cli/org): org add parity for Card/Key/Totp via shared builders 2026-06-20 18:31:29 -04:00
adlee-was-taken
07862b8d44 test(cli/org): failing Card/Key/Totp org add round-trips (B4, pre-A-integration)
Adds run_stdin + create_collection_and_grant fixture helpers and three
acceptance tests for org add card/key/totp. Red until B1/B2 wire the
subcommands (currently: unrecognized subcommand). Asserts org get masks
card number + key material without --show. Edit round-trips land with B3.
2026-06-20 18:26:11 -04:00
adlee-was-taken
b09e0ce036 merge: feature/v0.8.1-dev-a-foundation (v0.8.1 Dev-A) — shared item_build module + personal add/edit refactor + --*-stdin flags 2026-06-20 18:24:04 -04:00
adlee-was-taken
db4e05a193 merge: feature/v0.8.1-dev-d-server-hook (v0.8.1 Dev-D) — grant-scope org attachment write paths in pre-receive hook 2026-06-20 17:38:27 -04:00
adlee-was-taken
d32af594e4 feat(server): grant-scope org attachment write paths in pre-receive hook 2026-06-20 17:30:49 -04:00
9 changed files with 472 additions and 105 deletions

2
Cargo.lock generated
View File

@@ -2220,7 +2220,7 @@ dependencies = [
[[package]] [[package]]
name = "relicario-server" name = "relicario-server"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",

View File

@@ -6,12 +6,13 @@ use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use relicario_core::{ use relicario_core::{
generate_org_key, wrap_org_key, generate_org_key, wrap_org_key,
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, CollectionDef, Item, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
OrgRole, OrgMember, OrgRole, OrgMember,
encrypt_org_manifest, encrypt_org_manifest,
}; };
use crate::org_session::atomic_write; use crate::org_session::atomic_write;
use crate::commands::item_build as ib;
pub fn run_init(dir: &Path, name: &str) -> Result<()> { pub fn run_init(dir: &Path, name: &str) -> Result<()> {
// Create directory structure // Create directory structure
@@ -745,17 +746,20 @@ pub fn run_audit(
Ok(()) Ok(())
} }
/// Item kinds `org add` supports without interactive prompts. /// Item kinds `org add` supports. Secrets resolve via `--*-stdin` flags or an
/// interactive prompt inside the shared `item_build` builders.
pub enum OrgAddKind { pub enum OrgAddKind {
Login { Login {
title: String, title: String,
username: Option<String>, username: Option<String>,
url: Option<String>, url: Option<String>,
password: Option<String>, password: Option<String>,
password_stdin: bool,
}, },
SecureNote { SecureNote {
title: String, title: String,
body: String, body: Option<String>,
body_stdin: bool,
}, },
Identity { Identity {
title: String, title: String,
@@ -763,43 +767,56 @@ pub enum OrgAddKind {
email: Option<String>, email: Option<String>,
phone: Option<String>, phone: Option<String>,
}, },
Card {
title: String,
holder: Option<String>,
expiry: Option<String>,
kind: String,
number_stdin: bool,
cvv_stdin: bool,
pin_stdin: bool,
},
Key {
title: String,
label: Option<String>,
algorithm: Option<String>,
public_key: Option<String>,
material_stdin: bool,
},
Totp {
title: String,
issuer: Option<String>,
label: Option<String>,
secret: Option<String>,
secret_stdin: bool,
period: u32,
digits: u8,
algorithm: String,
},
// Document is added later by Dev-C.
} }
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> { fn build_org_item(kind: OrgAddKind) -> Result<Item> {
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore}; match kind {
use zeroize::Zeroizing; OrgAddKind::Login { title, username, url, password, password_stdin } => {
ib::build_login(title, username, url, password, password_stdin, false, None)
let mut item = match kind {
OrgAddKind::Login { title, username, url, password } => {
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = password.map(Zeroizing::new);
Item::new(title, ItemCore::Login(LoginCore {
username,
password,
url: parsed_url,
totp: None,
}))
} }
OrgAddKind::SecureNote { title, body } => { OrgAddKind::SecureNote { title, body, body_stdin } => {
Item::new(title, ItemCore::SecureNote(SecureNoteCore { ib::build_secure_note(title, body, body_stdin)
body: Zeroizing::new(body),
}))
} }
OrgAddKind::Identity { title, full_name, email, phone } => { OrgAddKind::Identity { title, full_name, email, phone } => {
Item::new(title, ItemCore::Identity(IdentityCore { ib::build_identity(title, full_name, email, phone, None)
full_name, }
address: None, OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } => {
phone, ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)
email, }
date_of_birth: None, OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } => {
})) ib::build_key(title, label, algorithm, public_key, material_stdin)
}
OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => {
ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)
}
} }
};
item.tags = tags;
Ok(item)
} }
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> { pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
@@ -816,7 +833,8 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
// …and the caller must hold a grant for it. // …and the caller must hold a grant for it.
UnlockedOrgVault::ensure_grant(&caller, collection)?; UnlockedOrgVault::ensure_grant(&caller, collection)?;
let item = build_org_item(kind, tags)?; let mut item = build_org_item(kind)?;
item.tags = tags;
let item_rel = vault.save_item(collection, &item)?; let item_rel = vault.save_item(collection, &item)?;
// Upsert the manifest entry (collection slug stored plaintext inside the // Upsert the manifest entry (collection slug stored plaintext inside the
@@ -973,21 +991,9 @@ fn resolve_org_query<'a>(
} }
} }
pub fn run_edit( pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) -> Result<()> {
dir: &Path,
query: &str,
title: Option<String>,
username: Option<String>,
url: Option<String>,
password: Option<String>,
body: Option<String>,
email: Option<String>,
phone: Option<String>,
full_name: Option<String>,
) -> Result<()> {
use relicario_core::time::now_unix; use relicario_core::time::now_unix;
use relicario_core::ItemCore; use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::org_session::open_org_vault(Some(dir))?; let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?; let caller = vault.current_member()?;
@@ -999,31 +1005,28 @@ pub fn run_edit(
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?; crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
let mut item = vault.load_item(&collection, &id)?; let mut item = vault.load_item(&collection, &id)?;
eprintln!(
"Editing: {} ({}) — leave a prompt blank to keep the current value.",
item.title,
item.id.as_str()
);
if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? {
item.title = v;
}
if let Some(t) = title { item.title = t; } let history = &mut item.field_history;
match &mut item.core { match &mut item.core {
ItemCore::Login(l) => { ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
if let Some(u) = username { l.username = Some(u); } ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
if let Some(u) = url { ItemCore::Identity(i) => ib::edit_identity(i)?,
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?); ItemCore::Card(c) => ib::edit_card(c, history)?,
} ItemCore::Key(k) => ib::edit_key(k, history)?,
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); } ItemCore::Document(_) => ib::edit_document_message(),
} ItemCore::Totp(t) => ib::edit_totp(t, history)?,
ItemCore::SecureNote(n) => {
if let Some(b) = body { n.body = Zeroizing::new(b); }
}
ItemCore::Identity(i) => {
if let Some(v) = full_name { i.full_name = Some(v); }
if let Some(v) = email { i.email = Some(v); }
if let Some(v) = phone { i.phone = Some(v); }
}
_ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
} }
item.modified = now_unix(); item.modified = now_unix();
let item_rel = vault.save_item(&collection, &item)?; let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?; let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection); upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?; vault.save_manifest(&manifest)?;
@@ -1035,12 +1038,14 @@ pub fn run_edit(
); );
let commit_msg = format!( let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}", "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() caller.display_name,
caller.member_id.as_str(),
collection,
item.id.as_str()
); );
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?; crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
println!("Updated {}", item.id.as_str());
Ok(()) Ok(())
} }

View File

@@ -535,18 +535,12 @@ pub(crate) enum OrgCommands {
List { List {
#[arg(long)] trashed: bool, #[arg(long)] trashed: bool,
}, },
/// Edit an org item's fields (flag-driven; blank flags keep current values). /// Edit an org item interactively (per-type prompts; blank keeps current).
Edit { Edit {
/// Item id or case-insensitive title substring. /// Item id or case-insensitive title substring.
query: String, query: String,
#[arg(long)] title: Option<String>, /// Replace the login TOTP secret from a QR image.
#[arg(long)] username: Option<String>, #[arg(long)] totp_qr: Option<std::path::PathBuf>,
#[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>,
#[arg(long)] body: Option<String>,
#[arg(long)] email: Option<String>,
#[arg(long)] phone: Option<String>,
#[arg(long)] full_name: Option<String>,
}, },
/// Soft-delete an org item (reversible via `org restore`). /// Soft-delete an org item (reversible via `org restore`).
Rm { query: String }, Rm { query: String },
@@ -566,13 +560,15 @@ pub(crate) enum OrgAddKind {
#[arg(long)] url: Option<String>, #[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>, #[arg(long)] password: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] password_stdin: bool,
}, },
/// A secure note. /// A secure note.
SecureNote { SecureNote {
#[arg(long)] collection: String, #[arg(long)] collection: String,
#[arg(long)] title: String, #[arg(long)] title: String,
#[arg(long)] body: String, #[arg(long)] body: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] body_stdin: bool,
}, },
/// An identity record. /// An identity record.
Identity { Identity {
@@ -583,6 +579,41 @@ pub(crate) enum OrgAddKind {
#[arg(long)] phone: Option<String>, #[arg(long)] phone: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
}, },
/// A payment card (number / cvv / pin entered via --*-stdin or prompt).
Card {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>,
#[arg(long, default_value = "credit")] kind: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] number_stdin: bool,
#[arg(long)] cvv_stdin: bool,
#[arg(long)] pin_stdin: bool,
},
/// A key / credential blob (material entered via --material-stdin or prompt).
Key {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] label: Option<String>,
#[arg(long)] algorithm: Option<String>,
#[arg(long)] public_key: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] material_stdin: bool,
},
/// A TOTP authenticator (base32 secret via --secret or --secret-stdin).
Totp {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] issuer: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] secret: Option<String>,
#[arg(long, default_value_t = 30)] period: u32,
#[arg(long, default_value_t = 6)] digits: u8,
#[arg(long, default_value = "sha1")] algorithm: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] secret_stdin: bool,
},
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -676,14 +707,14 @@ fn main() -> Result<()> {
OrgCommands::Add { kind } => { OrgCommands::Add { kind } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
let (collection, add_kind, tags) = match kind { let (collection, add_kind, tags) = match kind {
OrgAddKind::Login { collection, title, username, url, password, tags } => ( OrgAddKind::Login { collection, title, username, url, password, tags, password_stdin } => (
collection, collection,
commands::org::OrgAddKind::Login { title, username, url, password }, commands::org::OrgAddKind::Login { title, username, url, password, password_stdin },
tags, tags,
), ),
OrgAddKind::SecureNote { collection, title, body, tags } => ( OrgAddKind::SecureNote { collection, title, body, tags, body_stdin } => (
collection, collection,
commands::org::OrgAddKind::SecureNote { title, body }, commands::org::OrgAddKind::SecureNote { title, body, body_stdin },
tags, tags,
), ),
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => ( OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
@@ -691,6 +722,21 @@ fn main() -> Result<()> {
commands::org::OrgAddKind::Identity { title, full_name, email, phone }, commands::org::OrgAddKind::Identity { title, full_name, email, phone },
tags, tags,
), ),
OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => (
collection,
commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin },
tags,
),
OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => (
collection,
commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin },
tags,
),
OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => (
collection,
commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm },
tags,
),
}; };
commands::org::run_add(&d, &collection, add_kind, tags)?; commands::org::run_add(&d, &collection, add_kind, tags)?;
} }
@@ -702,9 +748,9 @@ fn main() -> Result<()> {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_list(&d, trashed)?; commands::org::run_list(&d, trashed)?;
} }
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => { OrgCommands::Edit { query, totp_qr } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?; commands::org::run_edit(&d, &query, totp_qr)?;
} }
OrgCommands::Rm { query } => { OrgCommands::Rm { query } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;

View File

@@ -152,7 +152,9 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
); );
for (label, args) in [ for (label, args) in [
("edit", vec!["org", "edit", "GitHub", "--username", "evil"]), // `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"]), ("rm", vec!["org", "rm", "GitHub"]),
("restore", vec!["org", "restore", "GitHub"]), ("restore", vec!["org", "restore", "GitHub"]),
("purge", vec!["org", "purge", "GitHub"]), ("purge", vec!["org", "purge", "GitHub"]),
@@ -170,13 +172,12 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
} }
// The item is untouched: the owner can still read the original password and // 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. // 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_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string(); 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_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("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("alice"), "ungranted member must not have modified the item: {owner_out}");
assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}");
} }
#[test] #[test]

View File

@@ -67,6 +67,39 @@ impl OrgFixture {
let v: serde_json::Value = serde_json::from_str(&s).unwrap(); let v: serde_json::Value = serde_json::from_str(&s).unwrap();
v["members"][0]["member_id"].as_str().unwrap().to_string() v["members"][0]["member_id"].as_str().unwrap().to_string()
} }
/// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
/// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
/// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
use std::io::Write as _;
let mut child = Command::cargo_bin("relicario")
.unwrap()
.env("XDG_CONFIG_HOME", &self.xdg)
.env("RELICARIO_ORG_DIR", self.vault.path())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
child.wait_with_output().unwrap()
}
/// Create collection `slug` and grant the owner access to it — the common
/// setup the item-type round-trip tests share.
fn create_collection_and_grant(&self, slug: &str) {
let owner = self.owner_member_id();
assert!(
self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
"create-collection {slug} failed",
);
assert!(
self.run(&["org", "grant", &owner, slug]).status.success(),
"grant {slug} failed",
);
}
} }
#[test] #[test]
@@ -151,21 +184,17 @@ fn org_add_rejects_unknown_collection() {
#[test] #[test]
fn org_edit_updates_fields_and_commits_update_trailer() { fn org_edit_updates_fields_and_commits_update_trailer() {
let f = OrgFixture::new(); let f = OrgFixture::new();
let owner = f.owner_member_id(); f.create_collection_and_grant("prod");
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
assert!(f.run(&[ assert!(f.run(&[
"org", "add", "login", "--collection", "prod", "org", "add", "login", "--collection", "prod",
"--title", "Mail", "--username", "old", "--password", "pw", "--title", "Mail", "--username", "old", "--password", "pw",
]).status.success()); ]).status.success());
// Edit the username. // org edit is now interactive per-type: keep title, set username=new-user,
let out = f.run(&[ // keep URL, decline password change.
"org", "edit", "Mail", "--username", "new-user", let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n");
]);
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
// get --show reflects the new username.
let out = f.run(&["org", "get", "Mail", "--show"]); let out = f.run(&["org", "get", "Mail", "--show"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("new-user"), "edit did not take: {stdout}"); assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
@@ -215,3 +244,215 @@ fn org_rm_restore_purge_cycle() {
let body = String::from_utf8_lossy(&log.stdout).to_string(); let body = String::from_utf8_lossy(&log.stdout).to_string();
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}"); assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
} }
// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
// must mask every secret unless `--show` is passed — asserted below.
#[test]
fn org_add_card_via_stdin_then_get_masks_secret() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_card reads number, then cvv, then pin — one line each, in that order.
let out = f.run_stdin(
&[
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
],
"4111111111111111\n123\n4321\n",
);
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
// get masks the card number by default.
let got = f.run(&["org", "get", "Corp Visa"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");
// --show reveals it.
let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
}
#[test]
fn org_add_key_via_stdin_then_get_masks_material() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_key reads key material from stdin to EOF (multiline secret).
let out = f.run_stdin(
&[
"org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
"--label", "ci", "--algorithm", "ed25519", "--material-stdin",
],
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
);
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Deploy Key"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Label: ci"), "label missing: {stdout}");
assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
}
#[test]
fn org_add_totp_with_secret_flag_round_trips() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// Totp accepts the base32 secret via --secret (no stdin needed).
let out = f.run(&[
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
]);
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "AWS root"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("AWS root"), "title missing: {stdout}");
assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}");
}
#[test]
fn org_edit_card_interactive_changes_holder() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let out = f.run_stdin(
&[
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
],
"4111111111111111\n123\n4321\n",
);
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
// Interactive edit: keep title, set holder, decline number change.
let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n");
assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Corp Visa"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}");
assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}");
assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}");
}
#[test]
fn org_edit_totp_interactive_changes_issuer() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
assert!(f.run(&[
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
]).status.success());
// Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change.
let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n");
assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "AWS root"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take");
}
// --- grant enforcement + remaining --*-stdin paths for the new types ---------
#[test]
fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() {
let f = OrgFixture::new();
// `secret` exists but is NOT granted to the owner.
assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success());
// ensure_grant runs before any secret prompt in run_add, so these need no
// stdin — each new type must be rejected for a collection it lacks a grant for.
for args in [
vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"],
vec!["org", "add", "key", "--collection", "secret", "--title", "X"],
vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
] {
let out = f.run(&args);
assert!(!out.status.success(), "ungranted add must fail: {args:?}");
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(err.contains("access denied") || err.contains("grant"),
"expected grant denial for {args:?}: {err}");
}
// …and rejected for a nonexistent collection.
for args in [
vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"],
vec!["org", "add", "key", "--collection", "ghost", "--title", "X"],
vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
] {
let out = f.run(&args);
assert!(!out.status.success(), "unknown-collection add must fail: {args:?}");
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(err.contains("does not exist") || err.contains("ghost"),
"expected unknown-collection error for {args:?}: {err}");
}
}
#[test]
fn org_add_secure_note_via_body_stdin_masks_body() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_secure_note(body_stdin=true) reads the body from stdin to EOF.
let out = f.run_stdin(
&["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"],
"line one\nsuper-secret-line\n",
);
assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Runbook"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}");
assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}");
let shown = f.run(&["org", "get", "Runbook", "--show"]);
assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show");
}
#[test]
fn org_add_totp_via_secret_stdin_round_trips() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_totp(secret_stdin=true) reads one base32 line from stdin.
let out = f.run_stdin(
&["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"],
"JBSWY3DPEHPK3PXP\n",
);
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "VPN"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: Corp"), "issuer missing");
}
#[test]
fn org_edit_key_replaces_material_and_reveals_with_show() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let out = f.run_stdin(
&["org", "add", "key", "--collection", "eng", "--title", "Signing Key",
"--label", "ci", "--material-stdin"],
"OLD-MATERIAL-aaaa\n",
);
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
// Interactive edit: keep title, ACCEPT "Replace key material?" -> new material
// read from stdin to EOF (edit_key). Exercises the accept branch + history push.
let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n");
assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr));
let masked = f.run(&["org", "get", "Signing Key"]);
let masked = String::from_utf8_lossy(&masked.stdout).to_string();
assert!(masked.contains("********"), "material must be masked without --show: {masked}");
assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}");
let shown = f.run(&["org", "get", "Signing Key", "--show"]);
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "relicario-server" name = "relicario-server"
version = "0.1.0" version = "0.1.1"
edition = "2021" edition = "2021"
description = "Pre-receive Git hook for relicario password manager" description = "Pre-receive Git hook for relicario password manager"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@@ -6,7 +6,8 @@
pub enum PathClass { pub enum PathClass {
/// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write. /// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
Protected, Protected,
/// `items/<slug>/<id>.enc` — writer must hold a grant for `<slug>`. /// `items/<slug>/<id>.enc` and `attachments/<slug>/<item-id>/<att-id>.enc` —
/// writer must hold a grant for `<slug>`.
Item { collection: String }, Item { collection: String },
/// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the /// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
/// per-commit signature check (signer must be a current member). /// per-commit signature check (signer must be a current member).
@@ -42,6 +43,23 @@ pub fn classify_path(path: &str) -> PathClass {
return PathClass::Item { collection: slug.to_string() }; return PathClass::Item { collection: slug.to_string() };
} }
if let Some(rest) = path.strip_prefix("attachments/") {
// Expect exactly: <slug>/<item-id>/<att-id>.enc → three segments.
let segments: Vec<&str> = rest.split('/').collect();
if segments.len() != 3 {
return PathClass::Rejected(
"attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string());
}
let slug = segments[0];
if slug.is_empty() {
return PathClass::Rejected("empty collection slug in attachments path".to_string());
}
if slug.contains('.') {
return PathClass::Rejected(format!("invalid collection slug: {:?}", slug));
}
return PathClass::Item { collection: slug.to_string() };
}
PathClass::Unrestricted PathClass::Unrestricted
} }

View File

@@ -79,3 +79,43 @@ fn extract_schema_version_errors_on_missing_field() {
fn extract_schema_version_errors_on_garbage() { fn extract_schema_version_errors_on_garbage() {
assert!(extract_schema_version("not json").is_err()); assert!(extract_schema_version("not json").is_err());
} }
#[test]
fn attachment_path_is_collection_scoped() {
assert_eq!(
classify_path("attachments/prod/a1b2c3d4e5f6a1b2/0011223344556677.enc"),
PathClass::Item { collection: "prod".to_string() }
);
}
#[test]
fn attachment_wrong_segment_count_is_rejected() {
assert_eq!(
classify_path("attachments/prod/onlytwo.enc"),
PathClass::Rejected("attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string())
);
}
#[test]
fn attachment_empty_or_dotted_slug_is_rejected() {
assert!(matches!(classify_path("attachments//item/att.enc"), PathClass::Rejected(_)));
assert!(matches!(classify_path("attachments/../item/att.enc"), PathClass::Rejected(_)));
}
#[test]
fn attachments_prefix_alone_is_rejected_not_unrestricted() {
// `attachments/` with no slug/item/att segments must be Rejected, NOT fall
// through to Unrestricted — that fall-through was the authz gap this closes.
assert!(matches!(classify_path("attachments/"), PathClass::Rejected(_)));
}
#[test]
fn attachment_att_id_segment_may_contain_dots() {
// The `.`-free guard applies to the slug (segment[0]) ONLY; the att-id segment
// legitimately carries `.enc` and is unharmed by additional dots — proving the
// guard is not a blanket "reject any dotted segment".
assert_eq!(
classify_path("attachments/eng/a1b2c3d4e5f6a1b2/00112233.aux.enc"),
PathClass::Item { collection: "eng".to_string() }
);
}

View File

@@ -111,10 +111,11 @@ before they land.
rejected outright. rejected outright.
2. **Path-level write authorisation** — each modified path is classified by 2. **Path-level write authorisation** — each modified path is classified by
`classify_path` (`crates/relicario-server/src/lib.rs:19`) into `classify_path` (`crates/relicario-server/src/lib.rs:20`) into
`ProtectedJson` (owner/admin write only), `CollectionItem` (the `Protected` (owner/admin write only), `Item { collection }` (the
`items/<slug>/…` prefix; write allowed only if the slug appears in the `items/<slug>/…` or `attachments/<slug>/…` prefix; write allowed only if
signer's `collections` grant array), or `Unrestricted`. The write is the slug appears in the signer's `collections` grant array), or
`Unrestricted`. The write is
authorised if and only if the signer's role and grants satisfy the authorised if and only if the signer's role and grants satisfy the
classification. Item blobs are authorised by the leading path segment classification. Item blobs are authorised by the leading path segment
alone — the ciphertext is never decrypted by the hook. alone — the ciphertext is never decrypted by the hook.
@@ -132,6 +133,21 @@ before they land.
Merge commits are rejected. A genesis commit (no parents) is allowed Merge commits are rejected. A genesis commit (no parents) is allowed
only when it is signed by the sole Owner it introduces. only when it is signed by the sole Owner it introduces.
#### Attachment write authorisation (v0.1.1 fix)
Prior to `relicario-server` v0.1.1, `attachments/…` paths fell through to
`PathClass::Unrestricted` in `classify_path`
(`crates/relicario-server/src/lib.rs:20`). Any member with push access could
write attachment blobs to any collection regardless of their grants. As of
v0.1.1, `attachments/<slug>/<item-id>/<att-id>.enc` is classified as
`PathClass::Item { collection: slug }`, bringing attachment writes under the
same grant check already applied to `items/<slug>/<id>.enc` blobs.
**Deploying this fix requires rebuilding and redeploying the pre-receive hook
on the server.** A server still running a hook built before v0.1.1 continues
to accept attachment pushes from any member; the `Unrestricted` path is only
closed once the updated hook is installed at `<repo>/hooks/pre-receive`.
### Key rotation ### Key rotation
`relicario org rotate-key` generates a fresh 256-bit org master key, `relicario org rotate-key` generates a fresh 256-bit org master key,