feat(cli/org): org add — collection-scoped typed item create with grant guard

This commit is contained in:
adlee-was-taken
2026-06-20 14:00:21 -04:00
parent 6a16523ee0
commit 87b1d166c2
3 changed files with 336 additions and 5 deletions

View File

@@ -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,
}; };
@@ -744,6 +745,132 @@ 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(())
}
/// 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::*;

View File

@@ -507,8 +507,42 @@ pub(crate) enum OrgCommands {
#[arg(long, default_value = "table")] #[arg(long, default_value = "table")]
format: String, format: String,
}, },
// Item subcommands (Add/Get/List/Edit/Rm/Restore/Purge) are added by /// Add an item to a collection in the org vault.
// Tasks B10B13, which extend this enum. Add {
#[command(subcommand)]
kind: OrgAddKind,
},
// Item subcommands (Get/List/Edit/Rm/Restore/Purge) are added by
// Tasks B11B13, which extend this enum.
}
#[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<()> {
@@ -599,8 +633,29 @@ fn main() -> Result<()> {
commands::org::run_audit(&d, since.as_deref(), member.as_deref(), commands::org::run_audit(&d, since.as_deref(), member.as_deref(),
collection.as_deref(), action.as_deref(), &format)?; collection.as_deref(), action.as_deref(), &format)?;
} }
// Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by OrgCommands::Add { kind } => {
// Tasks B10B13. 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)?;
}
// Item dispatch arms (Get/List/Edit/Rm/Restore/Purge) added by
// Tasks B11B13.
} }
Ok(()) Ok(())
} }

View File

@@ -0,0 +1,149 @@
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}");
}