feat(cli/org): org add — collection-scoped typed item create with grant guard
This commit is contained in:
@@ -6,7 +6,8 @@ use std::path::Path;
|
||||
use anyhow::{Context, Result};
|
||||
use relicario_core::{
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -744,6 +745,132 @@ pub fn run_audit(
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -507,8 +507,42 @@ pub(crate) enum OrgCommands {
|
||||
#[arg(long, default_value = "table")]
|
||||
format: String,
|
||||
},
|
||||
// Item subcommands (Add/Get/List/Edit/Rm/Restore/Purge) are added by
|
||||
// Tasks B10–B13, which extend this enum.
|
||||
/// Add an item to a collection in the org vault.
|
||||
Add {
|
||||
#[command(subcommand)]
|
||||
kind: OrgAddKind,
|
||||
},
|
||||
// Item subcommands (Get/List/Edit/Rm/Restore/Purge) are added by
|
||||
// Tasks B11–B13, 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<()> {
|
||||
@@ -599,8 +633,29 @@ fn main() -> Result<()> {
|
||||
commands::org::run_audit(&d, since.as_deref(), member.as_deref(),
|
||||
collection.as_deref(), action.as_deref(), &format)?;
|
||||
}
|
||||
// Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by
|
||||
// Tasks B10–B13.
|
||||
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)?;
|
||||
}
|
||||
// Item dispatch arms (Get/List/Edit/Rm/Restore/Purge) added by
|
||||
// Tasks B11–B13.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
149
crates/relicario-cli/tests/org_items.rs
Normal file
149
crates/relicario-cli/tests/org_items.rs
Normal 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}");
|
||||
}
|
||||
Reference in New Issue
Block a user