feat(cli/org): wire Commands::Org admin subcommands + parse_org_role + transfer-ownership/delete-org

This commit is contained in:
adlee-was-taken
2026-06-20 13:50:11 -04:00
parent cdb008c900
commit 6a16523ee0
2 changed files with 197 additions and 2 deletions

View File

@@ -441,6 +441,73 @@ pub fn run_rotate_key(dir: &Path) -> Result<()> {
Ok(()) 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<()> { pub fn run_status(dir: &Path) -> Result<()> {
let root = crate::org_session::org_dir(Some(dir))?; let root = crate::org_session::org_dir(Some(dir))?;

View File

@@ -438,7 +438,77 @@ pub(crate) enum OrgCommands {
#[arg(long)] #[arg(long)]
name: String, 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<String>,
#[arg(long)]
member: Option<String>,
#[arg(long)]
collection: Option<String>,
#[arg(long)]
action: Option<String>,
/// 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 B10B13, which extend this enum.
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -481,13 +551,71 @@ fn main() -> Result<()> {
OrgCommands::Init { name } => { OrgCommands::Init { name } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_init(&d, &name)?; 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 B10B13.
} }
Ok(())
} }
} }
} }
fn parse_org_role(s: &str) -> anyhow::Result<relicario_core::OrgRole> {
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). /// Check for test passphrase override (debug builds only; stripped from release).
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
pub(crate) fn test_passphrase_override() -> Option<String> { pub(crate) fn test_passphrase_override() -> Option<String> {