//! `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 { 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 = 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//.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, pub actor_id: Option, /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking). pub trailer_actor_id: Option, pub action: Option, pub collection: Option, pub item_id: Option, pub device_id: Option, /// 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: " " (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 ` (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 = 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 = 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(""), 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: ". 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())); } }