//! `relicario org` subcommands for multi-user org vault management. use std::fs; 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, encrypt_org_manifest, }; use crate::org_session::atomic_write; pub fn run_init(dir: &Path, name: &str) -> Result<()> { // Create directory structure fs::create_dir_all(dir.join("items")).context("create items/")?; fs::create_dir_all(dir.join("keys")).context("create keys/")?; // Get caller's device info let device_pubkey = crate::device::current_device_pubkey() .context("read device key — run `relicario device add` first")?; // Generate org master key let org_key = generate_org_key(); // Wrap org key to caller's device key let wrapped = wrap_org_key(&org_key, &device_pubkey) .context("wrap org key to device key")?; // Create initial members.json with caller as owner let caller_id = MemberId::new(); let now = relicario_core::now_unix(); let member = OrgMember { member_id: caller_id.clone(), display_name: whoami(), role: OrgRole::Owner, ed25519_pubkey: device_pubkey, collections: vec![], added_at: now, added_by: caller_id.clone(), }; let mut members = OrgMembers::new(); members.members.push(member); // Write wrapped key let key_path = dir.join("keys").join(format!("{}.enc", caller_id.as_str())); fs::write(&key_path, &wrapped).context("write caller key blob")?; // Write org.json let meta = OrgMeta::new(name.to_string()); let meta_json = serde_json::to_string_pretty(&meta)?; atomic_write(&dir.join("org.json"), meta_json.as_bytes())?; // Write members.json let members_json = serde_json::to_string_pretty(&members)?; atomic_write(&dir.join("members.json"), members_json.as_bytes())?; // Write collections.json (empty) let collections = OrgCollections::new(); let coll_json = serde_json::to_string_pretty(&collections)?; atomic_write(&dir.join("collections.json"), coll_json.as_bytes())?; // Write empty manifest.enc let manifest = OrgManifest::new(); let manifest_bytes = encrypt_org_manifest(&manifest, &org_key)?; atomic_write(&dir.join("manifest.enc"), &manifest_bytes)?; // git init, then configure THIS repo to sign commits with the active device // key. Org commits must be signed; the pre-receive hook verifies every one. crate::helpers::git_run(dir, &["init"], "git init")?; let device_name = crate::device::current_device()? .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; crate::device::configure_git_signing(dir, &device_name) .context("configure org repo signing")?; // Stage everything and make the signed bootstrap commit via org_git_run // (which does NOT disable signing, unlike helpers::git_run). crate::org_session::org_git_run(dir, &["add", "."], "git add")?; let commit_msg = format!( "init: org vault \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: org-init", members.members[0].display_name, caller_id.as_str() ); crate::org_session::org_git_run(dir, &["commit", "-m", &commit_msg], "git commit")?; println!("Org vault initialized at {}", dir.display()); println!("Your member ID: {}", caller_id.as_str()); Ok(()) } fn whoami() -> String { std::env::var("USER") .or_else(|_| std::env::var("USERNAME")) .unwrap_or_else(|_| "unknown".into()) } pub fn run_add_member( dir: &Path, pubkey: &str, name: &str, role: OrgRole, ) -> Result<()> { let vault = crate::org_session::open_org_vault(Some(dir))?; let caller = vault.current_member()?; if !caller.role.can_manage_members() { anyhow::bail!("only owners and admins can add members"); } // Privilege-escalation guard: only an owner may create an owner or admin. if matches!(role, OrgRole::Owner | OrgRole::Admin) && !caller.role.can_manage_owners() { anyhow::bail!("only owners can add members with the owner or admin role"); } let mut members = vault.load_members()?; // Check pubkey not already present if members.members.iter().any(|m| m.ed25519_pubkey.trim() == pubkey.trim()) { anyhow::bail!("this public key is already registered in the org"); } let new_id = MemberId::new(); let now = relicario_core::now_unix(); let wrapped = wrap_org_key(vault.key(), pubkey) .context("wrap org key to new member's key")?; fs::write(vault.member_key_path(&new_id), &wrapped) .context("write member key blob")?; members.members.push(OrgMember { member_id: new_id.clone(), display_name: name.to_string(), role, ed25519_pubkey: pubkey.trim().to_string(), collections: vec![], added_at: now, added_by: caller.member_id.clone(), }); vault.save_members(&members)?; let commit_msg = format!( "org: add member \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-add\nRelicario-Member: {}", caller.display_name, caller.member_id.as_str(), new_id.as_str() ); crate::org_session::org_git_run( &vault.root, &["add", "members.json", &format!("keys/{}.enc", new_id.as_str())], "git add", )?; crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; println!("Added {} ({})", name, new_id.as_str()); Ok(()) } pub fn run_remove_member(dir: &Path, member_id_prefix: &str) -> Result<()> { let vault = crate::org_session::open_org_vault(Some(dir))?; let caller = vault.current_member()?; if !caller.role.can_manage_members() { anyhow::bail!("only owners and admins can remove members"); } let mut members = vault.load_members()?; let target_id = resolve_member_id(&members, member_id_prefix)?; let target = members.find_by_id(&target_id).unwrap(); if target.role == OrgRole::Owner && !caller.role.can_manage_owners() { anyhow::bail!("only owners can remove other owners"); } let target_name = target.display_name.clone(); // Delete key blob let key_path = vault.member_key_path(&target_id); if key_path.exists() { fs::remove_file(&key_path).context("delete key blob")?; } members.members.retain(|m| m.member_id != target_id); vault.save_members(&members)?; let commit_msg = format!( "org: remove member \"{target_name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-remove\nRelicario-Member: {}", caller.display_name, caller.member_id.as_str(), target_id.as_str() ); crate::org_session::org_git_run( &vault.root, &["add", "members.json", &format!("keys/{}.enc", target_id.as_str())], "git add", )?; crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; eprintln!("⚠ Run `relicario org rotate-key --dir {}` to complete revocation.", vault.root.display()); println!("Removed {}", target_name); Ok(()) } pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result<()> { let vault = crate::org_session::open_org_vault(Some(dir))?; let caller = vault.current_member()?; let mut members = vault.load_members()?; let target_id = resolve_member_id(&members, member_id_prefix)?; if matches!(role, OrgRole::Admin | OrgRole::Owner) && !caller.role.can_manage_owners() { anyhow::bail!("only owners can promote to admin or owner"); } if !caller.role.can_manage_members() { anyhow::bail!("only owners and admins can change roles"); } let target = members.find_by_id_mut(&target_id) .ok_or_else(|| anyhow::anyhow!("member not found"))?; let old_role = target.role; target.role = role; vault.save_members(&members)?; let commit_msg = format!( "org: set role {} → {:?}\n\nRelicario-Actor: {} {}\nRelicario-Action: member-role-change\nRelicario-Member: {}", target_id.as_str(), role, 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")?; println!("Changed role {:?} → {:?}", old_role, role); Ok(()) } /// Resolve a member_id prefix (or full ID) to a MemberId. fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { let hits: Vec<_> = members.members.iter() .filter(|m| m.member_id.as_str().starts_with(prefix)) .collect(); match hits.len() { 0 => anyhow::bail!("no member matches `{prefix}`"), 1 => Ok(hits[0].member_id.clone()), _ => anyhow::bail!("ambiguous prefix `{prefix}` — {} matches", hits.len()), } } #[cfg(test)] mod tests { use super::*; use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember}; fn alice() -> OrgMember { OrgMember { member_id: MemberId::new(), display_name: "Alice".into(), role: OrgRole::Member, ed25519_pubkey: "ssh-ed25519 AAAA fake".into(), collections: vec![], added_at: 0, added_by: MemberId::new(), } } #[test] fn set_role_changes_role() { let mut members = OrgMembers::new(); let a = alice(); let id = a.member_id.clone(); members.members.push(a); if let Some(m) = members.find_by_id_mut(&id) { m.role = OrgRole::Admin; } assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin); } }