feat(cli/org): add-member (owner-only escalation guard), remove-member, set-role

This commit is contained in:
adlee-was-taken
2026-06-20 12:38:48 -04:00
parent dbdb3f6ab0
commit 1ad8eb0918

View File

@@ -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<MemberId> {
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);
}
}