758 lines
29 KiB
Rust
758 lines
29 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(())
|
|
}
|
|
|
|
pub fn run_create_collection(dir: &Path, slug: &str, display_name: &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 create collections");
|
|
}
|
|
|
|
let mut collections = vault.load_collections()?;
|
|
if collections.contains_slug(slug) {
|
|
anyhow::bail!("collection `{slug}` already exists");
|
|
}
|
|
if slug.is_empty() || slug.contains('/') || slug.contains('.') {
|
|
anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string");
|
|
}
|
|
|
|
collections.collections.push(CollectionDef {
|
|
slug: slug.to_string(),
|
|
display_name: display_name.to_string(),
|
|
created_by: caller.member_id.clone(),
|
|
created_at: relicario_core::now_unix(),
|
|
});
|
|
vault.save_collections(&collections)?;
|
|
|
|
let commit_msg = format!(
|
|
"org: create collection \"{slug}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-create\nRelicario-Collection: {slug}",
|
|
caller.display_name, caller.member_id.as_str()
|
|
);
|
|
crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?;
|
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
|
|
|
|
println!("Created collection `{slug}`");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &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 grant collection access");
|
|
}
|
|
|
|
let collections = vault.load_collections()?;
|
|
if !collections.contains_slug(slug) {
|
|
anyhow::bail!("collection `{slug}` does not exist — create it first");
|
|
}
|
|
|
|
let mut members = vault.load_members()?;
|
|
let target_id = resolve_member_id(&members, member_id_prefix)?;
|
|
let target = members.find_by_id_mut(&target_id).unwrap();
|
|
if target.collections.contains(&slug.to_string()) {
|
|
anyhow::bail!("member already has access to `{slug}`");
|
|
}
|
|
target.collections.push(slug.to_string());
|
|
vault.save_members(&members)?;
|
|
|
|
let commit_msg = format!(
|
|
"org: grant {slug} to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\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")?;
|
|
|
|
println!("Granted `{slug}` to {}", target_id.as_str());
|
|
Ok(())
|
|
}
|
|
|
|
pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &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 revoke collection access");
|
|
}
|
|
|
|
let mut members = vault.load_members()?;
|
|
let target_id = resolve_member_id(&members, member_id_prefix)?;
|
|
let target = members.find_by_id_mut(&target_id).unwrap();
|
|
if !target.collections.contains(&slug.to_string()) {
|
|
anyhow::bail!("member does not have access to `{slug}`");
|
|
}
|
|
target.collections.retain(|s| s != slug);
|
|
vault.save_members(&members)?;
|
|
|
|
let commit_msg = format!(
|
|
"org: revoke {slug} from {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\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")?;
|
|
|
|
println!("Revoked `{slug}` from {}", target_id.as_str());
|
|
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()),
|
|
}
|
|
}
|
|
|
|
pub fn run_rotate_key(dir: &Path) -> Result<()> {
|
|
// Pull latest state first to detect a concurrent rotation. We must
|
|
// distinguish three outcomes:
|
|
// * success -> proceed
|
|
// * no upstream / no remote -> local-only org, proceed
|
|
// * non-fast-forward / conflict -> concurrent rotation, ABORT
|
|
let pull = std::process::Command::new("git")
|
|
.current_dir(dir)
|
|
.args(["pull", "--rebase"])
|
|
.output()
|
|
.context("spawn git pull --rebase")?;
|
|
if !pull.status.success() {
|
|
let stderr = String::from_utf8_lossy(&pull.stderr);
|
|
let no_upstream = stderr.contains("no tracking information")
|
|
|| stderr.contains("There is no tracking information")
|
|
|| stderr.contains("does not appear to be a git repository")
|
|
|| stderr.contains("Could not read from remote repository")
|
|
|| stderr.contains("No remote repository specified");
|
|
if no_upstream {
|
|
eprintln!("Note: no upstream configured; proceeding with local state.");
|
|
} else {
|
|
// Best-effort: leave the working tree clean for the retry.
|
|
let _ = std::process::Command::new("git")
|
|
.current_dir(dir)
|
|
.args(["rebase", "--abort"])
|
|
.output();
|
|
anyhow::bail!(
|
|
"Concurrent key rotation detected — pull and re-run org rotate-key."
|
|
);
|
|
}
|
|
}
|
|
|
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
|
let caller = vault.current_member()?;
|
|
if !caller.role.can_manage_owners() {
|
|
anyhow::bail!("only owners can rotate the org master key");
|
|
}
|
|
|
|
let members = vault.load_members()?;
|
|
let new_org_key = relicario_core::generate_org_key();
|
|
|
|
// Re-wrap the org key for every current member.
|
|
let mut staged_paths: Vec<String> = Vec::new();
|
|
for member in &members.members {
|
|
let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey)
|
|
.with_context(|| format!("wrap key for {}", member.display_name))?;
|
|
let key_path = vault.member_key_path(&member.member_id);
|
|
crate::org_session::atomic_write(&key_path, &wrapped)
|
|
.with_context(|| format!("write key for {}", member.display_name))?;
|
|
staged_paths.push(format!("keys/{}.enc", member.member_id.as_str()));
|
|
}
|
|
|
|
// Re-encrypt EVERY item blob under the new key. Items live collection-scoped
|
|
// at items/<collection-slug>/<id>.enc. Decrypt with the old key (held in the
|
|
// open vault session) and re-encrypt with the new one, in place. Without this
|
|
// a removed member who kept the old key + a clone could still decrypt every
|
|
// pre-rotation item.
|
|
let items_root = vault.root().join("items");
|
|
if items_root.is_dir() {
|
|
for slug_entry in fs::read_dir(&items_root).context("read items/")? {
|
|
let slug_entry = slug_entry.context("read items/ entry")?;
|
|
let slug_dir = slug_entry.path();
|
|
if !slug_dir.is_dir() {
|
|
continue;
|
|
}
|
|
let slug = slug_entry.file_name().to_string_lossy().to_string();
|
|
for item_entry in fs::read_dir(&slug_dir)
|
|
.with_context(|| format!("read items/{slug}/"))?
|
|
{
|
|
let item_entry = item_entry.context("read item entry")?;
|
|
let item_path = item_entry.path();
|
|
if item_path.extension().and_then(|e| e.to_str()) != Some("enc") {
|
|
continue;
|
|
}
|
|
let old_bytes = fs::read(&item_path)
|
|
.with_context(|| format!("read {}", item_path.display()))?;
|
|
let item = relicario_core::decrypt_item(&old_bytes, vault.key())
|
|
.with_context(|| format!("decrypt {}", item_path.display()))?;
|
|
let new_bytes = relicario_core::encrypt_item(&item, &new_org_key)
|
|
.with_context(|| format!("re-encrypt {}", item_path.display()))?;
|
|
crate::org_session::atomic_write(&item_path, &new_bytes)?;
|
|
let file_name = item_entry.file_name().to_string_lossy().to_string();
|
|
staged_paths.push(format!("items/{slug}/{file_name}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-encrypt the manifest with the new key.
|
|
let manifest = vault.load_manifest()?;
|
|
let new_manifest_bytes = relicario_core::encrypt_org_manifest(&manifest, &new_org_key)?;
|
|
crate::org_session::atomic_write(&vault.manifest_path(), &new_manifest_bytes)?;
|
|
staged_paths.push("manifest.enc".to_string());
|
|
|
|
// Commit
|
|
let mut add_args = vec!["add"];
|
|
let path_refs: Vec<&str> = staged_paths.iter().map(|s| s.as_str()).collect();
|
|
add_args.extend_from_slice(&path_refs);
|
|
crate::org_session::org_git_run(&vault.root, &add_args, "git add")?;
|
|
|
|
let commit_msg = format!(
|
|
"org: rotate org master key\n\nRelicario-Actor: {} {}\nRelicario-Action: key-rotate",
|
|
caller.display_name, caller.member_id.as_str()
|
|
);
|
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
|
|
|
|
println!(
|
|
"Key rotated. {} member key(s) re-wrapped; all item blobs + manifest re-encrypted.",
|
|
members.members.len()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn run_status(dir: &Path) -> Result<()> {
|
|
let root = crate::org_session::org_dir(Some(dir))?;
|
|
|
|
let meta: relicario_core::OrgMeta = {
|
|
let s = fs::read_to_string(root.join("org.json")).context("read org.json")?;
|
|
serde_json::from_str(&s)?
|
|
};
|
|
let members: OrgMembers = {
|
|
let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
|
|
serde_json::from_str(&s)?
|
|
};
|
|
let collections: OrgCollections = {
|
|
let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?;
|
|
serde_json::from_str(&s)?
|
|
};
|
|
|
|
println!("Org: {} ({})", meta.display_name, meta.org_id.as_str());
|
|
println!();
|
|
println!("Members ({}):", members.members.len());
|
|
for m in &members.members {
|
|
let colls = if m.collections.is_empty() {
|
|
"(no collections)".to_string()
|
|
} else {
|
|
m.collections.join(", ")
|
|
};
|
|
println!(" {:?} {} {} [{}]", m.role, m.member_id.as_str(), m.display_name, colls);
|
|
}
|
|
println!();
|
|
println!("Collections ({}):", collections.collections.len());
|
|
for c in &collections.collections {
|
|
println!(" {} — {}", c.slug, c.display_name);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
pub struct AuditEvent {
|
|
pub commit: String,
|
|
pub timestamp: String,
|
|
/// Actor as resolved from the VERIFIED signing key (authoritative).
|
|
pub actor_name: Option<String>,
|
|
pub actor_id: Option<String>,
|
|
/// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
|
|
pub trailer_actor_id: Option<String>,
|
|
pub action: Option<String>,
|
|
pub collection: Option<String>,
|
|
pub item_id: Option<String>,
|
|
pub device_id: Option<String>,
|
|
/// True when the trailer's claimed actor disagrees with the verified signer,
|
|
/// or when no current member matches the signing key.
|
|
pub tampered: bool,
|
|
}
|
|
|
|
fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent {
|
|
let mut ev = AuditEvent {
|
|
commit: commit.to_string(),
|
|
timestamp: timestamp.to_string(),
|
|
actor_name: None,
|
|
actor_id: None,
|
|
trailer_actor_id: None,
|
|
action: None,
|
|
collection: None,
|
|
item_id: None,
|
|
device_id: None,
|
|
tampered: false,
|
|
};
|
|
for line in trailers.lines() {
|
|
let line = line.trim();
|
|
if let Some(rest) = line.strip_prefix("Relicario-Actor:") {
|
|
// Contract format: "<name> <member_id>" (member_id is the last token).
|
|
let rest = rest.trim();
|
|
if let Some((_name, id)) = rest.rsplit_once(' ') {
|
|
ev.trailer_actor_id = Some(id.trim().to_string());
|
|
} else if !rest.is_empty() {
|
|
ev.trailer_actor_id = Some(rest.to_string());
|
|
}
|
|
} else if let Some(v) = line.strip_prefix("Relicario-Action:") {
|
|
ev.action = Some(v.trim().to_string());
|
|
} else if let Some(v) = line.strip_prefix("Relicario-Collection:") {
|
|
ev.collection = Some(v.trim().to_string());
|
|
} else if let Some(v) = line.strip_prefix("Relicario-Item:") {
|
|
ev.item_id = Some(v.trim().to_string());
|
|
} else if let Some(v) = line.strip_prefix("Relicario-Device:") {
|
|
ev.device_id = Some(v.trim().to_string());
|
|
}
|
|
}
|
|
ev
|
|
}
|
|
|
|
/// Resolve a commit's SSH signature fingerprint to a current member, mirroring
|
|
/// the pre-receive hook: build an allowed_signers from members.json, inject it
|
|
/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from
|
|
/// stderr. Returns None if the commit is unsigned or the signer is not a member.
|
|
fn resolve_signer<'m>(
|
|
root: &Path,
|
|
commit: &str,
|
|
members: &'m relicario_core::OrgMembers,
|
|
) -> Option<&'m relicario_core::OrgMember> {
|
|
use std::io::Write;
|
|
let mut tmp = tempfile::NamedTempFile::new().ok()?;
|
|
for m in &members.members {
|
|
let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim());
|
|
}
|
|
let allowed_path = tmp.path();
|
|
|
|
let output = std::process::Command::new("git")
|
|
.current_dir(root)
|
|
.args(["verify-commit", "--raw", commit])
|
|
.env("GIT_CONFIG_COUNT", "1")
|
|
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
|
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
|
.output()
|
|
.ok()?;
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
|
|
let fp = re.captures(&stderr)?.get(1)?.as_str().to_string();
|
|
|
|
members.members.iter().find(|m| {
|
|
relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str())
|
|
})
|
|
}
|
|
|
|
pub fn run_audit(
|
|
dir: &Path,
|
|
since: Option<&str>,
|
|
member_filter: Option<&str>,
|
|
collection_filter: Option<&str>,
|
|
action_filter: Option<&str>,
|
|
format: &str,
|
|
) -> Result<()> {
|
|
// Spec surface is `--format <table|json>` (default table). Accept only those.
|
|
let json = match format {
|
|
"json" => true,
|
|
"table" => false,
|
|
other => anyhow::bail!("unknown --format `{other}` — use table or json"),
|
|
};
|
|
let root = crate::org_session::org_dir(Some(dir))?;
|
|
|
|
// members.json — needed to resolve each commit's verified signer to a member.
|
|
let members: relicario_core::OrgMembers = {
|
|
let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
|
|
serde_json::from_str(&s).context("parse members.json")?
|
|
};
|
|
|
|
// git log framed with a record separator (%x1e, U+001E) PER COMMIT and a
|
|
// field separator (%x1f, U+001F) between fields, so multi-line trailer
|
|
// values cannot misalign record boundaries. Committer date (%cI), not
|
|
// author date: it is what revocation/audit is anchored to.
|
|
let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)";
|
|
let mut args: Vec<String> = vec!["log".into(), format!("--format={fmt}")];
|
|
if let Some(s) = since {
|
|
args.push(format!("--since={s}"));
|
|
}
|
|
|
|
let output = std::process::Command::new("git")
|
|
.current_dir(&root)
|
|
.args(&args)
|
|
.output()
|
|
.context("git log")?;
|
|
let log = String::from_utf8_lossy(&output.stdout);
|
|
|
|
let mut events: Vec<AuditEvent> = Vec::new();
|
|
for record in log.split('\u{1e}') {
|
|
let record = record.trim_start_matches('\n');
|
|
if record.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let mut fields = record.splitn(3, '\u{1f}');
|
|
let commit = fields.next().unwrap_or("").trim();
|
|
let ts = fields.next().unwrap_or("").trim();
|
|
let trailers = fields.next().unwrap_or("");
|
|
if commit.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let mut ev = parse_trailer_block(commit, ts, trailers);
|
|
if ev.action.is_none() {
|
|
continue; // not an org commit
|
|
}
|
|
|
|
// Resolve the VERIFIED signer and attribute it as the authoritative actor.
|
|
match resolve_signer(&root, commit, &members) {
|
|
Some(m) => {
|
|
ev.actor_name = Some(m.display_name.clone());
|
|
ev.actor_id = Some(m.member_id.as_str().to_string());
|
|
// Tampered if the trailer claims a different actor than the signer.
|
|
if let Some(claimed) = ev.trailer_actor_id.as_deref() {
|
|
if claimed != m.member_id.as_str() {
|
|
ev.tampered = true;
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
// No current member matched the signature -> cannot trust the
|
|
// trailer's claimed actor.
|
|
ev.tampered = true;
|
|
}
|
|
}
|
|
|
|
if let Some(mid) = member_filter {
|
|
// Filter on the VERIFIED actor id, not the spoofable trailer.
|
|
if ev.actor_id.as_deref() != Some(mid) {
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(col) = collection_filter {
|
|
if ev.collection.as_deref() != Some(col) {
|
|
continue;
|
|
}
|
|
}
|
|
if let Some(act) = action_filter {
|
|
if ev.action.as_deref() != Some(act) {
|
|
continue;
|
|
}
|
|
}
|
|
events.push(ev);
|
|
}
|
|
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&events)?);
|
|
} else {
|
|
println!("{:<44} {:<26} {:<20} {:<18} {}", "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG");
|
|
for ev in &events {
|
|
println!("{:<44} {:<26} {:<20} {:<18} {}",
|
|
ev.commit,
|
|
ev.timestamp,
|
|
ev.action.as_deref().unwrap_or("-"),
|
|
ev.actor_name.as_deref().unwrap_or("<unverified>"),
|
|
if ev.tampered { "TAMPERED" } else { "" },
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};
|
|
|
|
#[test]
|
|
fn parse_trailers_extracts_relicario_fields() {
|
|
// Contract trailer shape: "Relicario-Actor: <name> <member_id>".
|
|
let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n";
|
|
let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw);
|
|
assert_eq!(event.action.as_deref(), Some("item-create"));
|
|
assert_eq!(event.collection.as_deref(), Some("prod"));
|
|
// The verified actor_id is resolved later from the signature, not the trailer;
|
|
// the trailer only populates trailer_actor_id here.
|
|
assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
|
|
assert_eq!(event.actor_id, None);
|
|
assert!(!event.tampered);
|
|
}
|
|
|
|
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 new_key_differs_from_old_key() {
|
|
let k1 = relicario_core::generate_org_key();
|
|
let k2 = relicario_core::generate_org_key();
|
|
assert_ne!(*k1, *k2);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn grant_adds_slug_to_member_collections() {
|
|
let mut members = OrgMembers::new();
|
|
let a = alice();
|
|
let id = a.member_id.clone();
|
|
members.members.push(a);
|
|
|
|
let m = members.find_by_id_mut(&id).unwrap();
|
|
if !m.collections.contains(&"prod".to_string()) {
|
|
m.collections.push("prod".to_string());
|
|
}
|
|
assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn revoke_removes_slug_from_member_collections() {
|
|
let mut members = OrgMembers::new();
|
|
let mut a = alice();
|
|
a.collections = vec!["prod".into(), "dev".into()];
|
|
let id = a.member_id.clone();
|
|
members.members.push(a);
|
|
|
|
let m = members.find_by_id_mut(&id).unwrap();
|
|
m.collections.retain(|s| s != "prod");
|
|
assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
|
|
assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
|
|
}
|
|
}
|