268 lines
9.7 KiB
Rust
268 lines
9.7 KiB
Rust
//! `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<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);
|
|
}
|
|
}
|