diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index e9ee641..6805d21 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -441,6 +441,73 @@ pub fn run_rotate_key(dir: &Path) -> Result<()> { 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<()> { let root = crate::org_session::org_dir(Some(dir))?; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 639c5be..3f95330 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -438,7 +438,77 @@ pub(crate) enum OrgCommands { #[arg(long)] 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, + #[arg(long)] + member: Option, + #[arg(long)] + collection: Option, + #[arg(long)] + action: Option, + /// Output format: `table` (default) or `json`. + #[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. } fn main() -> Result<()> { @@ -481,13 +551,71 @@ fn main() -> Result<()> { OrgCommands::Init { name } => { let d = crate::org_session::org_dir(dir_path)?; 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)?; + } + // Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by + // Tasks B10–B13. } + Ok(()) } } } +fn parse_org_role(s: &str) -> anyhow::Result { + 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). #[cfg(debug_assertions)] pub(crate) fn test_passphrase_override() -> Option {