merge(cli): dev-b B9-B14 — org item CRUD (add/get/list/edit/rm/restore/purge) + wire all 19 OrgCommands. Reviewed: authz+secrets clean; grant-denial regression test follow-up tracked
This commit is contained in:
@@ -6,7 +6,8 @@ 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, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember,
|
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
|
||||||
|
OrgRole, OrgMember,
|
||||||
encrypt_org_manifest,
|
encrypt_org_manifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -441,6 +442,73 @@ pub fn run_rotate_key(dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str, keep_owner: bool) -> Result<()> {
|
||||||
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
|
let caller = vault.current_member()?;
|
||||||
|
if !caller.role.can_manage_owners() {
|
||||||
|
anyhow::bail!("only an owner can transfer ownership");
|
||||||
|
}
|
||||||
|
let mut members = vault.load_members()?;
|
||||||
|
let target_id = resolve_member_id(&members, member_id_prefix)?;
|
||||||
|
if target_id == caller.member_id {
|
||||||
|
anyhow::bail!("you are already the owner");
|
||||||
|
}
|
||||||
|
// Promote the target to Owner.
|
||||||
|
{
|
||||||
|
let target = members.find_by_id_mut(&target_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("member not found"))?;
|
||||||
|
target.role = OrgRole::Owner;
|
||||||
|
}
|
||||||
|
// Real transfer: also demote the CALLER to Admin, unless --keep-owner was
|
||||||
|
// passed (explicit co-ownership). The spec says "owner → another member",
|
||||||
|
// so demotion is the default.
|
||||||
|
if !keep_owner {
|
||||||
|
if let Some(me) = members.find_by_id_mut(&caller.member_id) {
|
||||||
|
me.role = OrgRole::Admin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vault.save_members(&members)?;
|
||||||
|
|
||||||
|
let mode = if keep_owner { "co-ownership (caller kept owner)" } else { "caller demoted to admin" };
|
||||||
|
let commit_msg = format!(
|
||||||
|
"org: transfer ownership to {} ({mode})\n\nRelicario-Actor: {} {}\nRelicario-Action: ownership-transfer\nRelicario-Member: {}",
|
||||||
|
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
|
||||||
|
);
|
||||||
|
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
|
||||||
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
|
||||||
|
if keep_owner {
|
||||||
|
println!("Ownership shared with {} (you remain an owner).", target_id.as_str());
|
||||||
|
} else {
|
||||||
|
println!("Ownership transferred to {} (you are now an admin).", target_id.as_str());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_delete_org(dir: &Path, confirm: bool) -> Result<()> {
|
||||||
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
|
let caller = vault.current_member()?;
|
||||||
|
if !caller.role.can_manage_owners() {
|
||||||
|
anyhow::bail!("only an owner can delete the org");
|
||||||
|
}
|
||||||
|
if !confirm {
|
||||||
|
anyhow::bail!("refusing to delete org without --confirm");
|
||||||
|
}
|
||||||
|
let commit_msg = format!(
|
||||||
|
"org: delete org\n\nRelicario-Actor: {} {}\nRelicario-Action: org-delete",
|
||||||
|
caller.display_name, caller.member_id.as_str()
|
||||||
|
);
|
||||||
|
// Remove org files (the git history is retained as the audit record).
|
||||||
|
for f in ["org.json", "members.json", "collections.json", "manifest.enc"] {
|
||||||
|
let _ = fs::remove_file(vault.root.join(f));
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(vault.root.join("items"));
|
||||||
|
let _ = std::fs::remove_dir_all(vault.root.join("keys"));
|
||||||
|
crate::org_session::org_git_run(&vault.root, &["add", "-A"], "git add")?;
|
||||||
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
|
||||||
|
println!("Org deleted (git history retained as audit record).");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run_status(dir: &Path) -> Result<()> {
|
pub fn run_status(dir: &Path) -> Result<()> {
|
||||||
let root = crate::org_session::org_dir(Some(dir))?;
|
let root = crate::org_session::org_dir(Some(dir))?;
|
||||||
|
|
||||||
@@ -677,6 +745,416 @@ pub fn run_audit(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Item kinds `org add` supports without interactive prompts.
|
||||||
|
pub enum OrgAddKind {
|
||||||
|
Login {
|
||||||
|
title: String,
|
||||||
|
username: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
},
|
||||||
|
SecureNote {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
Identity {
|
||||||
|
title: String,
|
||||||
|
full_name: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
phone: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
|
||||||
|
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
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 } => {
|
||||||
|
Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
||||||
|
body: Zeroizing::new(body),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
OrgAddKind::Identity { title, full_name, email, phone } => {
|
||||||
|
Item::new(title, ItemCore::Identity(IdentityCore {
|
||||||
|
full_name,
|
||||||
|
address: None,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
date_of_birth: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
item.tags = tags;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
|
||||||
|
use crate::org_session::UnlockedOrgVault;
|
||||||
|
|
||||||
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
|
let caller = vault.current_member()?;
|
||||||
|
|
||||||
|
// Slug must exist in collections.json…
|
||||||
|
let collections = vault.load_collections()?;
|
||||||
|
if !collections.contains_slug(collection) {
|
||||||
|
anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
|
||||||
|
}
|
||||||
|
// …and the caller must hold a grant for it.
|
||||||
|
UnlockedOrgVault::ensure_grant(&caller, collection)?;
|
||||||
|
|
||||||
|
let item = build_org_item(kind, tags)?;
|
||||||
|
let item_rel = vault.save_item(collection, &item)?;
|
||||||
|
|
||||||
|
// Upsert the manifest entry (collection slug stored plaintext inside the
|
||||||
|
// encrypted manifest).
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
upsert_org_entry(&mut manifest, &item, collection);
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
|
||||||
|
let subject = format!(
|
||||||
|
"org add: {} ({})",
|
||||||
|
crate::helpers::sanitize_for_commit(&item.title),
|
||||||
|
item.id.as_str()
|
||||||
|
);
|
||||||
|
let commit_msg = format!(
|
||||||
|
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||||
|
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 add: git add",
|
||||||
|
)?;
|
||||||
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
|
||||||
|
|
||||||
|
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_list(dir: &Path, trashed: bool) -> Result<()> {
|
||||||
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
|
let caller = vault.current_member()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
|
// filter_for_member restricts to the caller's granted collections.
|
||||||
|
let visible = manifest.filter_for_member(&caller);
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = visible.entries.iter()
|
||||||
|
.filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() })
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
eprintln!("(no items match)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{:<16} {:<14} {:<12} TITLE", "ID", "TYPE", "COLLECTION");
|
||||||
|
for e in entries {
|
||||||
|
println!(
|
||||||
|
"{:<16} {:<14} {:<12} {}",
|
||||||
|
e.id.as_str(),
|
||||||
|
format!("{:?}", e.r#type),
|
||||||
|
e.collection,
|
||||||
|
e.title
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_get(dir: &Path, query: &str, show: bool) -> Result<()> {
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
|
let caller = vault.current_member()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let visible = manifest.filter_for_member(&caller);
|
||||||
|
|
||||||
|
let entry = resolve_org_query(&visible, query)?;
|
||||||
|
// Double-check the grant for the resolved collection (defense in depth).
|
||||||
|
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &entry.collection)?;
|
||||||
|
|
||||||
|
let item = vault.load_item(&entry.collection, &entry.id)?;
|
||||||
|
|
||||||
|
println!("ID: {}", item.id.as_str());
|
||||||
|
println!("Title: {}", item.title);
|
||||||
|
println!("Type: {:?}", item.r#type);
|
||||||
|
println!("Collection: {}", entry.collection);
|
||||||
|
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
||||||
|
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
||||||
|
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
||||||
|
ItemCore::Login(l) => {
|
||||||
|
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||||
|
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||||
|
l.password.clone()
|
||||||
|
}
|
||||||
|
ItemCore::SecureNote(n) => {
|
||||||
|
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||||
|
else { println!("Body: ********"); }
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ItemCore::Identity(i) => {
|
||||||
|
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
||||||
|
if let Some(v) = &i.email { println!("Email: {v}"); }
|
||||||
|
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ItemCore::Card(c) => {
|
||||||
|
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
||||||
|
c.number.clone()
|
||||||
|
}
|
||||||
|
ItemCore::Key(k) => {
|
||||||
|
if let Some(l) = &k.label { println!("Label: {l}"); }
|
||||||
|
Some(k.key_material.clone())
|
||||||
|
}
|
||||||
|
ItemCore::Document(d) => {
|
||||||
|
println!("Filename: {}", d.filename);
|
||||||
|
println!("MIME: {}", d.mime_type);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ItemCore::Totp(t) => {
|
||||||
|
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
||||||
|
if let Some(l) = &t.label { println!("Label: {l}"); }
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(secret) = primary_secret {
|
||||||
|
if show {
|
||||||
|
println!("Secret: {}", secret.as_str());
|
||||||
|
} else {
|
||||||
|
println!("Secret: ******** (use --show to reveal)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a query (exact id, else case-insensitive title substring) against an
|
||||||
|
/// already-grant-filtered manifest.
|
||||||
|
fn resolve_org_query<'a>(
|
||||||
|
manifest: &'a relicario_core::OrgManifest,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<&'a relicario_core::OrgManifestEntry> {
|
||||||
|
if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) {
|
||||||
|
return Ok(entry);
|
||||||
|
}
|
||||||
|
let needle = query.to_lowercase();
|
||||||
|
let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter()
|
||||||
|
.filter(|e| e.title.to_lowercase().contains(&needle))
|
||||||
|
.collect();
|
||||||
|
match hits.len() {
|
||||||
|
0 => anyhow::bail!("no item matches `{query}`"),
|
||||||
|
1 => Ok(hits[0]),
|
||||||
|
_ => {
|
||||||
|
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
|
||||||
|
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_edit(
|
||||||
|
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::ItemCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
|
let caller = vault.current_member()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let visible = manifest.filter_for_member(&caller);
|
||||||
|
let entry = resolve_org_query(&visible, query)?;
|
||||||
|
let collection = entry.collection.clone();
|
||||||
|
let id = entry.id.clone();
|
||||||
|
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
|
||||||
|
|
||||||
|
let mut item = vault.load_item(&collection, &id)?;
|
||||||
|
|
||||||
|
if let Some(t) = title { item.title = t; }
|
||||||
|
|
||||||
|
match &mut item.core {
|
||||||
|
ItemCore::Login(l) => {
|
||||||
|
if let Some(u) = username { l.username = Some(u); }
|
||||||
|
if let Some(u) = url {
|
||||||
|
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
|
||||||
|
}
|
||||||
|
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
let item_rel = vault.save_item(&collection, &item)?;
|
||||||
|
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
upsert_org_entry(&mut manifest, &item, &collection);
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
|
||||||
|
let subject = format!(
|
||||||
|
"org edit: {} ({})",
|
||||||
|
crate::helpers::sanitize_for_commit(&item.title),
|
||||||
|
item.id.as_str()
|
||||||
|
);
|
||||||
|
let commit_msg = format!(
|
||||||
|
"{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()
|
||||||
|
);
|
||||||
|
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")?;
|
||||||
|
|
||||||
|
println!("Updated {}", item.id.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a query to (collection, item) with grant enforcement. Used by the
|
||||||
|
/// trash-lifecycle commands.
|
||||||
|
fn open_org_item(
|
||||||
|
vault: &crate::org_session::UnlockedOrgVault,
|
||||||
|
caller: &relicario_core::OrgMember,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<(String, relicario_core::Item)> {
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let visible = manifest.filter_for_member(caller);
|
||||||
|
let entry = resolve_org_query(&visible, query)?;
|
||||||
|
let collection = entry.collection.clone();
|
||||||
|
let id = entry.id.clone();
|
||||||
|
crate::org_session::UnlockedOrgVault::ensure_grant(caller, &collection)?;
|
||||||
|
let item = vault.load_item(&collection, &id)?;
|
||||||
|
Ok((collection, item))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_rm(dir: &Path, query: &str) -> Result<()> {
|
||||||
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
|
let caller = vault.current_member()?;
|
||||||
|
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
|
||||||
|
|
||||||
|
item.soft_delete();
|
||||||
|
let item_rel = vault.save_item(&collection, &item)?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
upsert_org_entry(&mut manifest, &item, &collection);
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
|
||||||
|
let commit_msg = format!(
|
||||||
|
"org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-delete\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||||
|
crate::helpers::sanitize_for_commit(&item.title), 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 rm: git add")?;
|
||||||
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?;
|
||||||
|
println!("Moved to trash: {}", item.title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_restore(dir: &Path, query: &str) -> Result<()> {
|
||||||
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
|
let caller = vault.current_member()?;
|
||||||
|
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
|
||||||
|
|
||||||
|
item.restore();
|
||||||
|
let item_rel = vault.save_item(&collection, &item)?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
upsert_org_entry(&mut manifest, &item, &collection);
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
|
||||||
|
let commit_msg = format!(
|
||||||
|
"org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||||
|
crate::helpers::sanitize_for_commit(&item.title), 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 restore: git add")?;
|
||||||
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?;
|
||||||
|
println!("Restored: {}", item.title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
|
||||||
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
|
let caller = vault.current_member()?;
|
||||||
|
let (collection, item) = open_org_item(&vault, &caller, query)?;
|
||||||
|
let title = item.title.clone();
|
||||||
|
let id = item.id.clone();
|
||||||
|
|
||||||
|
// Remove the blob from disk, drop the manifest entry, stage with git rm.
|
||||||
|
vault.remove_item(&collection, &id)?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
manifest.entries.retain(|e| e.id != id);
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
|
||||||
|
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
|
||||||
|
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
|
||||||
|
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
|
||||||
|
|
||||||
|
let commit_msg = format!(
|
||||||
|
"org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||||
|
crate::helpers::sanitize_for_commit(&title), id.as_str(),
|
||||||
|
caller.display_name, caller.member_id.as_str(), collection, id.as_str()
|
||||||
|
);
|
||||||
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?;
|
||||||
|
println!("Purged: {title}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault
|
||||||
|
/// `Manifest::upsert`. Keyed by item id.
|
||||||
|
fn upsert_org_entry(
|
||||||
|
manifest: &mut relicario_core::OrgManifest,
|
||||||
|
item: &Item,
|
||||||
|
collection: &str,
|
||||||
|
) {
|
||||||
|
let entry = relicario_core::OrgManifestEntry {
|
||||||
|
id: item.id.clone(),
|
||||||
|
r#type: item.r#type,
|
||||||
|
title: item.title.clone(),
|
||||||
|
tags: item.tags.clone(),
|
||||||
|
modified: item.modified,
|
||||||
|
trashed_at: item.trashed_at,
|
||||||
|
collection: collection.to_string(),
|
||||||
|
};
|
||||||
|
if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) {
|
||||||
|
*slot = entry;
|
||||||
|
} else {
|
||||||
|
manifest.entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -438,7 +438,138 @@ pub(crate) enum OrgCommands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
// Admin + item subcommands are added by later tasks (B10-B14).
|
/// Add a member to the org.
|
||||||
|
AddMember {
|
||||||
|
/// OpenSSH ed25519 public key of the new member.
|
||||||
|
#[arg(long)]
|
||||||
|
key: String,
|
||||||
|
/// Display name.
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
/// Role: owner, admin, or member.
|
||||||
|
#[arg(long, default_value = "member")]
|
||||||
|
role: String,
|
||||||
|
},
|
||||||
|
/// Remove a member from the org.
|
||||||
|
RemoveMember {
|
||||||
|
/// Member ID prefix.
|
||||||
|
member_id: String,
|
||||||
|
},
|
||||||
|
/// Change a member's role.
|
||||||
|
SetRole {
|
||||||
|
member_id: String,
|
||||||
|
role: String,
|
||||||
|
},
|
||||||
|
/// Create a collection.
|
||||||
|
CreateCollection {
|
||||||
|
slug: String,
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Grant a member access to a collection.
|
||||||
|
Grant {
|
||||||
|
member_id: String,
|
||||||
|
collection: String,
|
||||||
|
},
|
||||||
|
/// Revoke a member's access to a collection.
|
||||||
|
Revoke {
|
||||||
|
member_id: String,
|
||||||
|
collection: String,
|
||||||
|
},
|
||||||
|
/// Rotate the org master key (run after removing a member).
|
||||||
|
RotateKey,
|
||||||
|
/// Transfer ownership to another member (owner only). By default the caller
|
||||||
|
/// is demoted to admin; pass --keep-owner for explicit co-ownership.
|
||||||
|
TransferOwnership {
|
||||||
|
member_id: String,
|
||||||
|
/// Keep the caller as an owner too (co-ownership) instead of demoting.
|
||||||
|
#[arg(long)]
|
||||||
|
keep_owner: bool,
|
||||||
|
},
|
||||||
|
/// Delete the org (owner only; requires --confirm).
|
||||||
|
DeleteOrg {
|
||||||
|
#[arg(long)]
|
||||||
|
confirm: bool,
|
||||||
|
},
|
||||||
|
/// Show org members and collections.
|
||||||
|
Status,
|
||||||
|
/// Query the org audit log.
|
||||||
|
Audit {
|
||||||
|
#[arg(long)]
|
||||||
|
since: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
member: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
collection: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
action: Option<String>,
|
||||||
|
/// Output format: `table` (default) or `json`.
|
||||||
|
#[arg(long, default_value = "table")]
|
||||||
|
format: String,
|
||||||
|
},
|
||||||
|
/// Add an item to a collection in the org vault.
|
||||||
|
Add {
|
||||||
|
#[command(subcommand)]
|
||||||
|
kind: OrgAddKind,
|
||||||
|
},
|
||||||
|
/// Print an org item (secrets masked unless --show).
|
||||||
|
Get {
|
||||||
|
/// Item id or case-insensitive title substring.
|
||||||
|
query: String,
|
||||||
|
#[arg(long)] show: bool,
|
||||||
|
},
|
||||||
|
/// List org items visible to you (filtered by your collection grants).
|
||||||
|
List {
|
||||||
|
#[arg(long)] trashed: bool,
|
||||||
|
},
|
||||||
|
/// Edit an org item's fields (flag-driven; blank flags keep current values).
|
||||||
|
Edit {
|
||||||
|
/// Item id or case-insensitive title substring.
|
||||||
|
query: String,
|
||||||
|
#[arg(long)] title: Option<String>,
|
||||||
|
#[arg(long)] username: Option<String>,
|
||||||
|
#[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`).
|
||||||
|
Rm { query: String },
|
||||||
|
/// Restore a soft-deleted org item.
|
||||||
|
Restore { query: String },
|
||||||
|
/// Permanently purge an org item (deletes the encrypted blob).
|
||||||
|
Purge { query: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand)]
|
||||||
|
pub(crate) enum OrgAddKind {
|
||||||
|
/// A login (username / url / password).
|
||||||
|
Login {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] username: Option<String>,
|
||||||
|
#[arg(long)] url: Option<String>,
|
||||||
|
#[arg(long)] password: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
|
/// A secure note.
|
||||||
|
SecureNote {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] body: String,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
|
/// An identity record.
|
||||||
|
Identity {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] full_name: Option<String>,
|
||||||
|
#[arg(long)] email: Option<String>,
|
||||||
|
#[arg(long)] phone: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -481,13 +612,114 @@ fn main() -> Result<()> {
|
|||||||
OrgCommands::Init { name } => {
|
OrgCommands::Init { name } => {
|
||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
commands::org::run_init(&d, &name)?;
|
commands::org::run_init(&d, &name)?;
|
||||||
Ok(())
|
}
|
||||||
|
OrgCommands::AddMember { key, name, role } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
let role = parse_org_role(&role)?;
|
||||||
|
commands::org::run_add_member(&d, &key, &name, role)?;
|
||||||
|
}
|
||||||
|
OrgCommands::RemoveMember { member_id } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_remove_member(&d, &member_id)?;
|
||||||
|
}
|
||||||
|
OrgCommands::SetRole { member_id, role } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
let role = parse_org_role(&role)?;
|
||||||
|
commands::org::run_set_role(&d, &member_id, role)?;
|
||||||
|
}
|
||||||
|
OrgCommands::CreateCollection { slug, name } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_create_collection(&d, &slug, &name)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Grant { member_id, collection } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_grant(&d, &member_id, &collection)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Revoke { member_id, collection } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_revoke(&d, &member_id, &collection)?;
|
||||||
|
}
|
||||||
|
OrgCommands::RotateKey => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_rotate_key(&d)?;
|
||||||
|
}
|
||||||
|
OrgCommands::TransferOwnership { member_id, keep_owner } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_transfer_ownership(&d, &member_id, keep_owner)?;
|
||||||
|
}
|
||||||
|
OrgCommands::DeleteOrg { confirm } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_delete_org(&d, confirm)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Status => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_status(&d)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Audit { since, member, collection, action, format } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_audit(&d, since.as_deref(), member.as_deref(),
|
||||||
|
collection.as_deref(), action.as_deref(), &format)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Add { kind } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
let (collection, add_kind, tags) = match kind {
|
||||||
|
OrgAddKind::Login { collection, title, username, url, password, tags } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::Login { title, username, url, password },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
|
OrgAddKind::SecureNote { collection, title, body, tags } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::SecureNote { title, body },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
|
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
commands::org::run_add(&d, &collection, add_kind, tags)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Get { query, show } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_get(&d, &query, show)?;
|
||||||
|
}
|
||||||
|
OrgCommands::List { trashed } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_list(&d, trashed)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Rm { query } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_rm(&d, &query)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Restore { query } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_restore(&d, &query)?;
|
||||||
|
}
|
||||||
|
OrgCommands::Purge { query } => {
|
||||||
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
commands::org::run_purge(&d, &query)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_org_role(s: &str) -> anyhow::Result<relicario_core::OrgRole> {
|
||||||
|
match s {
|
||||||
|
"owner" => Ok(relicario_core::OrgRole::Owner),
|
||||||
|
"admin" => Ok(relicario_core::OrgRole::Admin),
|
||||||
|
"member" => Ok(relicario_core::OrgRole::Member),
|
||||||
|
other => anyhow::bail!("unknown role `{other}` — use owner, admin, or member"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check for test passphrase override (debug builds only; stripped from release).
|
/// Check for test passphrase override (debug builds only; stripped from release).
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub(crate) fn test_passphrase_override() -> Option<String> {
|
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||||
|
|||||||
217
crates/relicario-cli/tests/org_items.rs
Normal file
217
crates/relicario-cli/tests/org_items.rs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
use assert_cmd::cargo::CommandCargoExt as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME.
|
||||||
|
struct OrgFixture {
|
||||||
|
_config: TempDir,
|
||||||
|
vault: TempDir,
|
||||||
|
xdg: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrgFixture {
|
||||||
|
/// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and
|
||||||
|
/// register it as the current device, then `org init`.
|
||||||
|
fn new() -> Self {
|
||||||
|
let config = TempDir::new().unwrap();
|
||||||
|
let xdg = config.path().to_path_buf();
|
||||||
|
let devices = xdg.join("relicario").join("devices").join("laptop");
|
||||||
|
std::fs::create_dir_all(&devices).unwrap();
|
||||||
|
|
||||||
|
// Generate an OpenSSH ed25519 keypair without a passphrase.
|
||||||
|
let keyfile = devices.join("signing.key");
|
||||||
|
let status = 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!(status.success(), "ssh-keygen failed");
|
||||||
|
// ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub.
|
||||||
|
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||||
|
// Mark this device current.
|
||||||
|
std::fs::write(
|
||||||
|
xdg.join("relicario").join("devices").join("current"),
|
||||||
|
"laptop\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let vault = TempDir::new().unwrap();
|
||||||
|
let f = OrgFixture { _config: config, vault, xdg };
|
||||||
|
|
||||||
|
let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]);
|
||||||
|
assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vault_path(&self) -> &Path { self.vault.path() }
|
||||||
|
fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() }
|
||||||
|
|
||||||
|
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||||
|
.env("RELICARIO_ORG_DIR", self.vault.path())
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
cmd.output().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner member id printed by `org init`/`org status`. We read it from
|
||||||
|
/// members.json directly to avoid parsing stdout.
|
||||||
|
fn owner_member_id(&self) -> String {
|
||||||
|
let s = std::fs::read_to_string(self.vault.path().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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_get_list_round_trip() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
let owner = f.owner_member_id();
|
||||||
|
|
||||||
|
// Create a collection and grant the owner access to it.
|
||||||
|
let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]);
|
||||||
|
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let out = f.run(&["org", "grant", &owner, "prod"]);
|
||||||
|
assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Add a login into the prod collection.
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "add", "login", "--collection", "prod",
|
||||||
|
"--title", "GitHub", "--username", "alice",
|
||||||
|
"--url", "https://github.com", "--password", "hunter2",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// The blob must live under items/prod/, NOT flat items/.
|
||||||
|
let prod_dir = f.vault_path().join("items").join("prod");
|
||||||
|
let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
|
||||||
|
assert_eq!(blobs.len(), 1, "expected one blob under items/prod/");
|
||||||
|
|
||||||
|
// list shows it.
|
||||||
|
let out = f.run(&["org", "list"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
|
||||||
|
|
||||||
|
// get masks by default.
|
||||||
|
let out = f.run(&["org", "get", "GitHub"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
assert!(stdout.contains("********"), "expected masked secret: {stdout}");
|
||||||
|
assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}");
|
||||||
|
|
||||||
|
// get --show reveals.
|
||||||
|
let out = f.run(&["org", "get", "GitHub", "--show"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}");
|
||||||
|
|
||||||
|
// The commit trailer records the action + collection + item.
|
||||||
|
let log = Command::new("git")
|
||||||
|
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||||
|
assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}");
|
||||||
|
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
||||||
|
assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_rejects_ungranted_collection() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
// Create the collection but do NOT grant the owner.
|
||||||
|
let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]);
|
||||||
|
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "add", "login", "--collection", "secret",
|
||||||
|
"--title", "X", "--username", "u", "--password", "p",
|
||||||
|
]);
|
||||||
|
assert!(!out.status.success(), "add into ungranted collection must fail");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_rejects_unknown_collection() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "add", "login", "--collection", "ghost",
|
||||||
|
"--title", "X", "--username", "u", "--password", "p",
|
||||||
|
]);
|
||||||
|
assert!(!out.status.success(), "add into nonexistent collection must fail");
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_updates_fields_and_commits_update_trailer() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
let owner = f.owner_member_id();
|
||||||
|
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
||||||
|
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
||||||
|
assert!(f.run(&[
|
||||||
|
"org", "add", "login", "--collection", "prod",
|
||||||
|
"--title", "Mail", "--username", "old", "--password", "pw",
|
||||||
|
]).status.success());
|
||||||
|
|
||||||
|
// Edit the username.
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "edit", "Mail", "--username", "new-user",
|
||||||
|
]);
|
||||||
|
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 stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
||||||
|
|
||||||
|
let log = Command::new("git")
|
||||||
|
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||||
|
.output().unwrap();
|
||||||
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||||
|
assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}");
|
||||||
|
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_rm_restore_purge_cycle() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
let owner = f.owner_member_id();
|
||||||
|
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
||||||
|
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
||||||
|
assert!(f.run(&[
|
||||||
|
"org", "add", "secure-note", "--collection", "prod",
|
||||||
|
"--title", "Recovery", "--body", "codes-here",
|
||||||
|
]).status.success());
|
||||||
|
|
||||||
|
// rm → appears only with --trashed.
|
||||||
|
assert!(f.run(&["org", "rm", "Recovery"]).status.success());
|
||||||
|
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
||||||
|
assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}");
|
||||||
|
let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
||||||
|
assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}");
|
||||||
|
|
||||||
|
// restore → back in default list.
|
||||||
|
assert!(f.run(&["org", "restore", "Recovery"]).status.success());
|
||||||
|
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
||||||
|
assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}");
|
||||||
|
|
||||||
|
// purge → blob gone, entry gone, item-purge trailer.
|
||||||
|
assert!(f.run(&["org", "purge", "Recovery"]).status.success());
|
||||||
|
let prod_dir = f.vault_path().join("items").join("prod");
|
||||||
|
let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
|
||||||
|
assert_eq!(count, 0, "blob not purged from items/prod/");
|
||||||
|
let listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
||||||
|
assert!(!listed.contains("Recovery"), "purged item still listed: {listed}");
|
||||||
|
|
||||||
|
let log = Command::new("git")
|
||||||
|
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||||
|
.output().unwrap();
|
||||||
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||||
|
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
||||||
|
}
|
||||||
206
crates/relicario-cli/tests/org_lifecycle.rs
Normal file
206
crates/relicario-cli/tests/org_lifecycle.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use assert_cmd::cargo::CommandCargoExt as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// A device home + an org vault. A second device can be wired for multi-member.
|
||||||
|
struct Dev {
|
||||||
|
xdg: PathBuf,
|
||||||
|
_config: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dev {
|
||||||
|
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());
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up an org with the owner granted `prod` and one login item in it.
|
||||||
|
fn setup_with_item() -> (Dev, TempDir, String) {
|
||||||
|
let dev = Dev::new("laptop");
|
||||||
|
let vault = TempDir::new().unwrap();
|
||||||
|
let v = vault.path();
|
||||||
|
assert!(dev.run(v, &["org", "init", "--dir", v.to_str().unwrap(), "--name", "Acme"]).status.success());
|
||||||
|
let owner = owner_member_id(v);
|
||||||
|
assert!(dev.run(v, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||||
|
assert!(dev.run(v, &["org", "grant", &owner, "prod"]).status.success());
|
||||||
|
assert!(dev.run(v, &[
|
||||||
|
"org", "add", "login", "--collection", "prod",
|
||||||
|
"--title", "GitHub", "--username", "alice", "--password", "hunter2",
|
||||||
|
]).status.success());
|
||||||
|
(dev, vault, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) audit --format json parses + has expected actions.
|
||||||
|
#[test]
|
||||||
|
fn audit_format_json_is_valid_and_has_actions() {
|
||||||
|
let (dev, vault, _owner) = setup_with_item();
|
||||||
|
let out = dev.run(vault.path(), &["org", "audit", "--format", "json"]);
|
||||||
|
assert!(out.status.success(), "audit json: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let events: serde_json::Value = serde_json::from_str(&stdout).expect("audit json must parse");
|
||||||
|
let arr = events.as_array().expect("array");
|
||||||
|
let actions: Vec<&str> = arr.iter()
|
||||||
|
.filter_map(|e| e["action"].as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(actions.contains(&"org-init"), "actions: {actions:?}");
|
||||||
|
assert!(actions.contains(&"collection-create"), "actions: {actions:?}");
|
||||||
|
assert!(actions.contains(&"item-create"), "actions: {actions:?}");
|
||||||
|
// Honest signer attribution: none of these should be TAMPERED (signer == trailer).
|
||||||
|
assert!(arr.iter().all(|e| e["tampered"] == serde_json::Value::Bool(false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) a forged-trailer commit is flagged TAMPERED.
|
||||||
|
#[test]
|
||||||
|
fn forged_trailer_commit_is_flagged_tampered() {
|
||||||
|
let (dev, vault, owner) = setup_with_item();
|
||||||
|
let v = vault.path();
|
||||||
|
|
||||||
|
// Hand-craft a SIGNED commit whose trailer CLAIMS a different actor id than
|
||||||
|
// the real signer. We reuse the org repo's own signing config (set by
|
||||||
|
// `org init`), so the commit verifies — but the trailer lies.
|
||||||
|
std::fs::write(v.join("decoy.txt"), "x").unwrap();
|
||||||
|
let git = |args: &[&str]| {
|
||||||
|
Command::new("git").current_dir(v).args(args)
|
||||||
|
.env("XDG_CONFIG_HOME", &dev.xdg)
|
||||||
|
.output().unwrap()
|
||||||
|
};
|
||||||
|
assert!(git(&["add", "decoy.txt"]).status.success());
|
||||||
|
let forged_msg = format!(
|
||||||
|
"forged\n\nRelicario-Actor: impostor ffffffffffffffff\nRelicario-Action: item-update\nRelicario-Member: {owner}"
|
||||||
|
);
|
||||||
|
// commit -S uses the repo's configured signing key (the real owner key).
|
||||||
|
let c = git(&["commit", "-S", "-m", &forged_msg]);
|
||||||
|
assert!(c.status.success(), "forged commit: {}", String::from_utf8_lossy(&c.stderr));
|
||||||
|
|
||||||
|
let out = dev.run(v, &["org", "audit", "--format", "json"]);
|
||||||
|
let events: serde_json::Value =
|
||||||
|
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
|
||||||
|
let forged = events.as_array().unwrap().iter()
|
||||||
|
.find(|e| e["action"] == "item-update")
|
||||||
|
.expect("forged item-update event present");
|
||||||
|
// Trailer claims ffff... but the verified signer is the owner → TAMPERED.
|
||||||
|
assert_eq!(forged["tampered"], serde_json::Value::Bool(true));
|
||||||
|
assert_eq!(forged["actor_id"].as_str(), Some(owner.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) concurrent rotate-key aborts with the exact spec error string.
|
||||||
|
#[test]
|
||||||
|
fn concurrent_rotate_key_aborts_with_spec_string() {
|
||||||
|
let (dev, vault, _owner) = setup_with_item();
|
||||||
|
let origin = TempDir::new().unwrap();
|
||||||
|
let v = vault.path();
|
||||||
|
let git = |args: &[&str]| Command::new("git").current_dir(v).args(args)
|
||||||
|
.env("XDG_CONFIG_HOME", &dev.xdg).output().unwrap();
|
||||||
|
|
||||||
|
// Make a bare origin and push, so a divergent upstream can be simulated.
|
||||||
|
assert!(Command::new("git").args(["init", "--bare", origin.path().to_str().unwrap()])
|
||||||
|
.output().unwrap().status.success());
|
||||||
|
assert!(git(&["remote", "add", "origin", origin.path().to_str().unwrap()]).status.success());
|
||||||
|
assert!(git(&["push", "-u", "origin", "HEAD"]).status.success());
|
||||||
|
|
||||||
|
// Diverge upstream: a second clone commits + pushes, writing to a SHARED file
|
||||||
|
// so that `git pull --rebase` will hit a merge conflict (add/add or edit/edit)
|
||||||
|
// and exit non-zero — which is how run_rotate_key detects a concurrent rotation.
|
||||||
|
let clone2 = TempDir::new().unwrap();
|
||||||
|
assert!(Command::new("git")
|
||||||
|
.args(["clone", origin.path().to_str().unwrap(), clone2.path().to_str().unwrap()])
|
||||||
|
.output().unwrap().status.success());
|
||||||
|
std::fs::write(clone2.path().join("conflict.txt"), "upstream-version").unwrap();
|
||||||
|
for a in [&["add", "conflict.txt"][..], &["-c", "user.email=u@u", "-c", "user.name=u", "commit", "-m", "upstream"][..], &["push", "origin", "HEAD:master"][..], &["push", "origin", "HEAD:main"][..]] {
|
||||||
|
let _ = Command::new("git").current_dir(clone2.path()).args(a).output();
|
||||||
|
}
|
||||||
|
// Local also writes conflict.txt with different content → add/add conflict on pull.
|
||||||
|
std::fs::write(v.join("conflict.txt"), "local-version").unwrap();
|
||||||
|
assert!(git(&["add", "conflict.txt"]).status.success());
|
||||||
|
assert!(git(&["-c", "commit.gpgsign=false", "commit", "-m", "local"]).status.success());
|
||||||
|
|
||||||
|
let out = dev.run(v, &["org", "rotate-key"]);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(!out.status.success(), "rotate-key should abort on a concurrent rotation");
|
||||||
|
assert!(
|
||||||
|
stderr.contains("Concurrent key rotation detected — pull and re-run org rotate-key."),
|
||||||
|
"missing spec error string: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (d) remove-member → rotate-key → old clone cannot decrypt; remaining member can.
|
||||||
|
#[test]
|
||||||
|
fn removed_member_clone_cannot_decrypt_after_rotation() {
|
||||||
|
// Owner laptop sets up the org + a second member "bob".
|
||||||
|
let (owner_dev, vault, _owner) = setup_with_item();
|
||||||
|
let v = vault.path();
|
||||||
|
let bob = Dev::new("bob-laptop");
|
||||||
|
let bob_pub = bob.pubkey("bob-laptop");
|
||||||
|
|
||||||
|
// Owner adds Bob and grants him prod.
|
||||||
|
assert!(owner_dev.run(v, &["org", "add-member", "--key", &bob_pub, "--name", "Bob", "--role", "member"]).status.success());
|
||||||
|
let members = std::fs::read_to_string(v.join("members.json")).unwrap();
|
||||||
|
let mv: serde_json::Value = serde_json::from_str(&members).unwrap();
|
||||||
|
let bob_id = mv["members"].as_array().unwrap().iter()
|
||||||
|
.find(|m| m["display_name"] == "Bob").unwrap()["member_id"].as_str().unwrap().to_string();
|
||||||
|
assert!(owner_dev.run(v, &["org", "grant", &bob_id, "prod"]).status.success());
|
||||||
|
|
||||||
|
// Bob clones the vault dir (his device, his key blob is present).
|
||||||
|
// `cp -r /vault /dst/` places contents at `/dst/<vault_basename>/` — use that
|
||||||
|
// sub-path, not the TempDir root, as the vault for Bob's commands.
|
||||||
|
let bob_clone = TempDir::new().unwrap();
|
||||||
|
let vault_basename = v.file_name().unwrap();
|
||||||
|
let cp = Command::new("cp").args(["-r", v.to_str().unwrap(), bob_clone.path().to_str().unwrap()]).output().unwrap();
|
||||||
|
assert!(cp.status.success());
|
||||||
|
let bob_vault = bob_clone.path().join(vault_basename);
|
||||||
|
// Bob can read the item BEFORE removal.
|
||||||
|
let pre = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
|
||||||
|
assert!(String::from_utf8_lossy(&pre.stdout).contains("hunter2"), "bob should read pre-removal");
|
||||||
|
|
||||||
|
// Owner removes Bob and rotates the key in the live vault.
|
||||||
|
assert!(owner_dev.run(v, &["org", "remove-member", &bob_id]).status.success());
|
||||||
|
assert!(owner_dev.run(v, &["org", "rotate-key"]).status.success());
|
||||||
|
|
||||||
|
// Owner (remaining member) can still decrypt in the live vault.
|
||||||
|
let owner_get = owner_dev.run(v, &["org", "get", "GitHub", "--show"]);
|
||||||
|
assert!(String::from_utf8_lossy(&owner_get.stdout).contains("hunter2"), "owner must still read");
|
||||||
|
|
||||||
|
// Copy the rotated item + manifest into Bob's stale clone (simulating a
|
||||||
|
// pull) — his OLD key blob can no longer unwrap the rotated org key.
|
||||||
|
let _ = Command::new("cp").args(["-r",
|
||||||
|
v.join("items").to_str().unwrap(), bob_vault.to_str().unwrap()]).output();
|
||||||
|
let _ = std::fs::copy(v.join("manifest.enc"), bob_vault.join("manifest.enc"));
|
||||||
|
let post = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
|
||||||
|
assert!(!post.status.success() || !String::from_utf8_lossy(&post.stdout).contains("hunter2"),
|
||||||
|
"removed member must NOT decrypt post-rotation: {}", String::from_utf8_lossy(&post.stdout));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user