From 1ad8eb091880ff345982af3b7ce9667f4c44827e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 12:38:48 -0400 Subject: [PATCH] feat(cli/org): add-member (owner-only escalation guard), remove-member, set-role --- crates/relicario-cli/src/commands/org.rs | 171 +++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index e2bc03a..276fa09 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -94,3 +94,174 @@ fn whoami() -> String { .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); + } +}