# Enterprise Org Vault Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement git-native multi-user org vaults for security-conscious self-hosting shops — per-user repos + a shared org repo, X25519-wrapped org master key per member, collections + role-based access, and a structured git-trailer audit trail. **Architecture:** The org repo is a separate git repository with a defined schema (`org.json`, `members.json`, `collections.json`, `keys/.enc`, `manifest.enc`, `items/*.enc`). Each member holds a copy of the 256-bit org master key wrapped (ECIES/X25519 + XChaCha20-Poly1305) to their existing ed25519 device key. All org management is CLI-only in this plan; extension integration is Plan B. The pre-receive hook in `relicario-server` gains an `org` mode enforcing role-based path authorization. **Tech Stack:** Rust, `x25519-dalek 2`, `ed25519-dalek 2`, `sha2`, `chacha20poly1305 0.10`, `ssh-key 0.6`, `serde_json`, `clap`, `anyhow`, `zeroize`, `tempfile` (tests). **Multi-stream assignment for PM:** - **Dev-A** — Tasks 1–4 (relicario-core org module). No dependencies. - **Dev-B** — Tasks 5–13 (relicario-cli org commands). Depends on Dev-A completing. - **Dev-C** — Task 14 (relicario-server hook extension). Depends on Dev-A completing. - **Integration** — Task 15. Depends on Dev-B and Dev-C completing. --- ## File Map | Action | Path | Responsibility | |---|---|---| | Create | `crates/relicario-core/src/org.rs` | Org types + crypto (IDs, members, collections, key wrap/unwrap) | | Modify | `crates/relicario-core/src/vault.rs` | Add `encrypt_org_manifest` / `decrypt_org_manifest` | | Modify | `crates/relicario-core/src/lib.rs` | `pub mod org` + re-exports | | Modify | `crates/relicario-core/Cargo.toml` | Add `x25519-dalek = "2"` | | Create | `crates/relicario-core/tests/org.rs` | Integration tests for org crypto | | Create | `crates/relicario-cli/src/org_session.rs` | `UnlockedOrgVault` session type | | Create | `crates/relicario-cli/src/commands/org.rs` | All `relicario org` subcommands | | Modify | `crates/relicario-cli/src/commands/mod.rs` | `pub mod org` | | Modify | `crates/relicario-cli/src/main.rs` | `Commands::Org` arm | | Modify | `crates/relicario-server/src/main.rs` | `verify-org-commit` subcommand | --- ## [Dev-A] Task 1: Add x25519-dalek and stub org module **Files:** - Modify: `crates/relicario-core/Cargo.toml` - Create: `crates/relicario-core/src/org.rs` (stub) - Modify: `crates/relicario-core/src/lib.rs` - [ ] **Step 1: Add x25519-dalek dependency** In `crates/relicario-core/Cargo.toml`, add after the `ed25519-dalek` line: ```toml x25519-dalek = { version = "2", features = ["static_secrets"] } ``` - [ ] **Step 2: Create org.rs stub** Create `crates/relicario-core/src/org.rs` with just a module-level comment: ```rust //! Org vault types, crypto, and schema for multi-user self-hosted deployments. ``` - [ ] **Step 3: Wire into lib.rs** In `crates/relicario-core/src/lib.rs`, add after the `device` module block: ```rust pub mod org; pub use org::{ CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole, generate_org_key, wrap_org_key, unwrap_org_key, }; ``` - [ ] **Step 4: Verify it compiles** ```bash cargo check -p relicario-core ``` Expected: compiles (stub is empty, re-exports will fail — that's fine until Task 2 defines them). Actually at this step the re-exports will fail. Wire the `pub mod org;` line only, no `pub use` yet: ```rust pub mod org; ``` Then add the `pub use` items incrementally as each Task defines the symbols. - [ ] **Step 5: Commit** ```bash git add crates/relicario-core/Cargo.toml crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs git commit -m "feat(core/org): add x25519-dalek dep + stub org module" ``` --- ## [Dev-A] Task 2: Org types — IDs, members, collections, org meta **Files:** - Modify: `crates/relicario-core/src/org.rs` - [ ] **Step 1: Write failing test for MemberId format** ```rust #[cfg(test)] mod tests { use super::*; #[test] fn member_id_is_16_hex_chars() { let id = MemberId::new(); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn member_ids_are_unique() { let mut seen = std::collections::HashSet::new(); for _ in 0..1_000 { assert!(seen.insert(MemberId::new().0)); } } #[test] fn org_id_is_16_hex_chars() { let id = OrgId::new(); assert_eq!(id.0.len(), 16); } } ``` Add this at the bottom of `org.rs`, run: ```bash cargo test -p relicario-core org::tests::member_id_is_16_hex_chars 2>&1 | tail -5 ``` Expected: FAIL — `MemberId` not defined. - [ ] **Step 2: Implement all org types** Replace the stub `org.rs` with: ```rust //! Org vault types, crypto, and schema for multi-user self-hosted deployments. use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; use crate::ids::ItemId; use crate::item_types::ItemType; // ── IDs ────────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct OrgId(pub String); #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct MemberId(pub String); impl OrgId { pub fn new() -> Self { let mut bytes = [0u8; 8]; OsRng.fill_bytes(&mut bytes); Self(hex::encode(bytes)) } pub fn as_str(&self) -> &str { &self.0 } } impl Default for OrgId { fn default() -> Self { Self::new() } } impl MemberId { pub fn new() -> Self { let mut bytes = [0u8; 8]; OsRng.fill_bytes(&mut bytes); Self(hex::encode(bytes)) } pub fn as_str(&self) -> &str { &self.0 } pub fn is_valid(&self) -> bool { self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit()) } } impl Default for MemberId { fn default() -> Self { Self::new() } } // ── Roles ──────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum OrgRole { Owner, Admin, Member, } impl OrgRole { pub fn can_manage_members(&self) -> bool { matches!(self, OrgRole::Owner | OrgRole::Admin) } pub fn can_manage_owners(&self) -> bool { matches!(self, OrgRole::Owner) } } // ── Members ────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgMember { pub member_id: MemberId, pub display_name: String, pub role: OrgRole, /// SSH public key string (openssh format: "ssh-ed25519 AAAA...") pub ed25519_pubkey: String, /// Collection slugs this member can access. #[serde(default)] pub collections: Vec, pub added_at: i64, pub added_by: MemberId, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgMembers { pub schema_version: u32, pub members: Vec, } impl OrgMembers { pub fn new() -> Self { Self { schema_version: 1, members: Vec::new() } } pub fn find_by_id(&self, id: &MemberId) -> Option<&OrgMember> { self.members.iter().find(|m| &m.member_id == id) } pub fn find_by_id_mut(&mut self, id: &MemberId) -> Option<&mut OrgMember> { self.members.iter_mut().find(|m| &m.member_id == id) } pub fn validate(&self) -> Result<()> { for m in &self.members { if !m.member_id.is_valid() { return Err(RelicarioError::Format( format!("invalid member_id: {}", m.member_id.0) )); } } Ok(()) } } impl Default for OrgMembers { fn default() -> Self { Self::new() } } // ── Collections ─────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollectionDef { pub slug: String, pub display_name: String, pub created_by: MemberId, pub created_at: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgCollections { pub schema_version: u32, pub collections: Vec, } impl OrgCollections { pub fn new() -> Self { Self { schema_version: 1, collections: Vec::new() } } pub fn contains_slug(&self, slug: &str) -> bool { self.collections.iter().any(|c| c.slug == slug) } pub fn validate(&self) -> Result<()> { for c in &self.collections { if c.slug.is_empty() || c.slug.contains('/') || c.slug.contains('.') { return Err(RelicarioError::Format( format!("invalid collection slug: {:?}", c.slug) )); } } Ok(()) } } impl Default for OrgCollections { fn default() -> Self { Self::new() } } // ── Org meta ───────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgMeta { pub schema_version: u32, pub org_id: OrgId, pub display_name: String, pub created_at: i64, } impl OrgMeta { pub fn new(display_name: String) -> Self { Self { schema_version: 1, org_id: OrgId::new(), display_name, created_at: crate::time::now_unix(), } } } // ── Org manifest ───────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgManifestEntry { pub id: ItemId, pub r#type: ItemType, pub title: String, #[serde(default)] pub tags: Vec, pub modified: i64, #[serde(skip_serializing_if = "Option::is_none")] pub trashed_at: Option, /// Collection this item belongs to. pub collection: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgManifest { pub schema_version: u32, pub entries: Vec, } impl OrgManifest { pub fn new() -> Self { Self { schema_version: 1, entries: Vec::new() } } /// Return only entries whose collection is in `member.collections`. pub fn filter_for_member(&self, member: &OrgMember) -> Self { let granted: std::collections::HashSet<&str> = member.collections.iter().map(|s| s.as_str()).collect(); Self { schema_version: self.schema_version, entries: self.entries.iter() .filter(|e| granted.contains(e.collection.as_str())) .cloned() .collect(), } } } impl Default for OrgManifest { fn default() -> Self { Self::new() } } // ── Key wrap / unwrap (ECIES: X25519 + XChaCha20-Poly1305) ─────────────────── /// Generate a random 256-bit org master key. pub fn generate_org_key() -> Zeroizing<[u8; 32]> { let mut key = Zeroizing::new([0u8; 32]); OsRng.fill_bytes(key.as_mut()); key } /// Derive an X25519 static secret from an ed25519 seed (standard RFC 7748 path). fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> x25519_dalek::StaticSecret { use sha2::{Digest, Sha512}; let h = Sha512::digest(seed.as_ref()); let mut scalar = [0u8; 32]; scalar.copy_from_slice(&h[..32]); // RFC 7748 clamping scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64; x25519_dalek::StaticSecret::from(scalar) } /// Parse an OpenSSH ed25519 public key string and return its X25519 form. fn openssh_ed25519_to_x25519_pk(openssh: &str) -> Result { use ssh_key::PublicKey; let pk = PublicKey::from_openssh(openssh.trim()) .map_err(|e| RelicarioError::Format(format!("bad SSH pubkey: {e}")))?; let ed_bytes = pk.key_data().ed25519() .ok_or_else(|| RelicarioError::Format("expected ed25519 key".into()))? .0; let verifying = ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes) .map_err(|e| RelicarioError::Format(format!("bad ed25519 pubkey: {e}")))?; Ok(x25519_dalek::PublicKey::from(verifying.to_montgomery().to_bytes())) } /// Wrap `org_key` for a recipient identified by their OpenSSH ed25519 public key. /// /// Output layout: `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag` pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &str) -> Result> { use sha2::{Digest, Sha256}; use x25519_dalek::EphemeralSecret; let recipient_pk = openssh_ed25519_to_x25519_pk(recipient_openssh_pubkey)?; let ephemeral_sk = EphemeralSecret::random_from_rng(OsRng); let ephemeral_pk = x25519_dalek::PublicKey::from(&ephemeral_sk); let shared = ephemeral_sk.diffie_hellman(&recipient_pk); // Domain-separated KDF let mut kdf_input = Vec::with_capacity(32 + 32 + 32); kdf_input.extend_from_slice(shared.as_bytes()); kdf_input.extend_from_slice(ephemeral_pk.as_bytes()); kdf_input.extend_from_slice(recipient_pk.as_bytes()); let wrap_key_hash = Sha256::digest(&kdf_input); let mut wrap_key = [0u8; 32]; wrap_key.copy_from_slice(&wrap_key_hash); let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?; let mut out = Vec::with_capacity(32 + encrypted.len()); out.extend_from_slice(ephemeral_pk.as_bytes()); out.extend_from_slice(&encrypted); Ok(out) } /// Unwrap a key blob produced by `wrap_org_key` using the recipient's ed25519 seed. pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Result> { use sha2::{Digest, Sha256}; // Minimum: 32 (ephemeral_pk) + 41 (version+nonce+tag for 32-byte plaintext) if wrapped.len() < 32 + 41 { return Err(RelicarioError::Format("wrapped key blob too short".into())); } let ephemeral_pk = x25519_dalek::PublicKey::from( <[u8; 32]>::try_from(&wrapped[..32]).unwrap() ); let encrypted = &wrapped[32..]; let recipient_sk = ed25519_seed_to_x25519_secret(ed25519_seed.as_ref()); let recipient_pk = x25519_dalek::PublicKey::from(&recipient_sk); let shared = recipient_sk.diffie_hellman(&ephemeral_pk); let mut kdf_input = Vec::with_capacity(32 + 32 + 32); kdf_input.extend_from_slice(shared.as_bytes()); kdf_input.extend_from_slice(ephemeral_pk.as_bytes()); kdf_input.extend_from_slice(recipient_pk.as_bytes()); let wrap_key_hash = Sha256::digest(&kdf_input); let mut wrap_key = [0u8; 32]; wrap_key.copy_from_slice(&wrap_key_hash); let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?); if plaintext.len() != 32 { return Err(RelicarioError::Format( format!("unwrapped key has wrong length: {}", plaintext.len()) )); } let mut key = Zeroizing::new([0u8; 32]); key.copy_from_slice(&plaintext); Ok(key) } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn member_id_is_16_hex_chars() { let id = MemberId::new(); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn member_ids_are_unique() { let mut seen = std::collections::HashSet::new(); for _ in 0..1_000 { assert!(seen.insert(MemberId::new().0)); } } #[test] fn org_id_is_16_hex_chars() { let id = OrgId::new(); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn org_role_can_manage_members() { assert!(OrgRole::Owner.can_manage_members()); assert!(OrgRole::Admin.can_manage_members()); assert!(!OrgRole::Member.can_manage_members()); } #[test] fn collection_slug_validation_rejects_slash() { let mut c = OrgCollections::new(); c.collections.push(CollectionDef { slug: "bad/slug".into(), display_name: "Bad".into(), created_by: MemberId::new(), created_at: 0, }); assert!(c.validate().is_err()); } #[test] fn filter_for_member_restricts_collections() { let mut manifest = OrgManifest::new(); manifest.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: crate::item_types::ItemType::SecureNote, title: "A".into(), tags: vec![], modified: 0, trashed_at: None, collection: "prod".into(), }); manifest.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: crate::item_types::ItemType::SecureNote, title: "B".into(), tags: vec![], modified: 0, trashed_at: None, collection: "dev".into(), }); let member = OrgMember { member_id: MemberId::new(), display_name: "Alice".into(), role: OrgRole::Member, ed25519_pubkey: String::new(), collections: vec!["prod".into()], added_at: 0, added_by: MemberId::new(), }; let filtered = manifest.filter_for_member(&member); assert_eq!(filtered.entries.len(), 1); assert_eq!(filtered.entries[0].collection, "prod"); } #[test] fn generate_org_key_is_32_bytes() { let key = generate_org_key(); assert_eq!(key.len(), 32); } #[test] fn wrap_unwrap_round_trip() { // Generate an ed25519 keypair to act as the member's device key use ed25519_dalek::SigningKey; let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); let pubkey_openssh = ssh_key::PrivateKey::from(signing_key.clone()) .public_key() .to_openssh() .expect("openssh"); let org_key = generate_org_key(); let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap"); let seed_zeroizing = Zeroizing::new(seed); let unwrapped = unwrap_org_key(&wrapped, &seed_zeroizing).expect("unwrap"); assert_eq!(*org_key, *unwrapped); } #[test] fn unwrap_with_wrong_seed_fails() { use ed25519_dalek::SigningKey; let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); let pubkey_openssh = ssh_key::PrivateKey::from(signing_key) .public_key() .to_openssh() .expect("openssh"); let org_key = generate_org_key(); let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap"); let wrong_seed = Zeroizing::new([0xFFu8; 32]); let result = unwrap_org_key(&wrapped, &wrong_seed); assert!(result.is_err()); } } ``` - [ ] **Step 3: Run tests** ```bash cargo test -p relicario-core org:: 2>&1 | tail -20 ``` Expected: all org tests pass. - [ ] **Step 4: Update lib.rs re-exports** Add to `crates/relicario-core/src/lib.rs` (replace the `pub mod org;` stub line): ```rust pub mod org; pub use org::{ generate_org_key, unwrap_org_key, wrap_org_key, CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole, }; ``` ```bash cargo check -p relicario-core ``` Expected: clean compile. - [ ] **Step 5: Commit** ```bash git add crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs git commit -m "feat(core/org): org types, manifest, and X25519 key wrap/unwrap" ``` --- ## [Dev-A] Task 3: Org manifest vault wrappers **Files:** - Modify: `crates/relicario-core/src/vault.rs` - [ ] **Step 1: Write failing tests** Add to `crates/relicario-core/src/vault.rs` tests block: ```rust #[test] fn org_manifest_round_trip() { use crate::org::{OrgManifest, OrgManifestEntry, MemberId}; use crate::ids::ItemId; use crate::item_types::ItemType; let mut m = OrgManifest::new(); m.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: ItemType::SecureNote, title: "test".into(), tags: vec![], modified: 0, trashed_at: None, collection: "prod".into(), }); let key = key(); let bytes = encrypt_org_manifest(&m, &key).unwrap(); let decoded = decrypt_org_manifest(&bytes, &key).unwrap(); assert_eq!(decoded.entries.len(), 1); assert_eq!(decoded.entries[0].collection, "prod"); } ``` ```bash cargo test -p relicario-core vault::tests::org_manifest_round_trip 2>&1 | tail -5 ``` Expected: FAIL — `encrypt_org_manifest` not defined. - [ ] **Step 2: Add org manifest wrappers to vault.rs** Add to `crates/relicario-core/src/vault.rs` (after the existing `decrypt_settings` function): ```rust use crate::org::OrgManifest; pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result> { let json = serde_json::to_vec(manifest)?; let plaintext = Zeroizing::new(json); encrypt(org_key, plaintext.as_slice()) } pub fn decrypt_org_manifest(encrypted: &[u8], org_key: &Zeroizing<[u8; 32]>) -> Result { let plaintext = decrypt(org_key, encrypted)?; let plaintext = Zeroizing::new(plaintext); let manifest: OrgManifest = serde_json::from_slice(&plaintext)?; Ok(manifest) } ``` Also add the re-exports in `lib.rs` vault pub use block: ```rust pub use vault::{ decrypt_item, decrypt_manifest, decrypt_org_manifest, decrypt_settings, encrypt_item, encrypt_manifest, encrypt_org_manifest, encrypt_settings, }; ``` - [ ] **Step 3: Run tests** ```bash cargo test -p relicario-core vault::tests::org_manifest_round_trip ``` Expected: PASS. ```bash cargo test -p relicario-core ``` Expected: all tests pass. - [ ] **Step 4: Commit** ```bash git add crates/relicario-core/src/vault.rs crates/relicario-core/src/lib.rs git commit -m "feat(core/org): encrypt/decrypt_org_manifest vault wrappers" ``` --- ## [Dev-B] Task 4: UnlockedOrgVault session type **Files:** - Create: `crates/relicario-cli/src/org_session.rs` - [ ] **Step 1: Write failing test** Create `crates/relicario-cli/src/org_session.rs` with just the test: ```rust #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; use std::fs; fn make_org_dir() -> TempDir { let dir = TempDir::new().unwrap(); let root = dir.path(); fs::create_dir_all(root.join("items")).unwrap(); fs::create_dir_all(root.join("keys")).unwrap(); dir } #[test] fn unlocked_org_vault_paths() { let dir = make_org_dir(); let root = dir.path().to_path_buf(); let key = zeroize::Zeroizing::new([0u8; 32]); let vault = UnlockedOrgVault { root: root.clone(), org_key: key }; assert_eq!(vault.manifest_path(), root.join("manifest.enc")); assert_eq!(vault.member_key_path(&relicario_core::MemberId("abc0def1abc0def1".into())), root.join("keys/abc0def1abc0def1.enc")); } } ``` ```bash cargo test -p relicario-cli org_session 2>&1 | tail -5 ``` Expected: FAIL — `UnlockedOrgVault` not defined. - [ ] **Step 2: Implement UnlockedOrgVault** ```rust //! Unlocked org vault session: holds the org master key for the duration of a //! CLI invocation. use std::fs; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use zeroize::Zeroizing; use relicario_core::{ decrypt_org_manifest, encrypt_org_manifest, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, }; pub struct UnlockedOrgVault { pub root: PathBuf, pub org_key: Zeroizing<[u8; 32]>, } impl UnlockedOrgVault { pub fn root(&self) -> &Path { &self.root } pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key } pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") } pub fn item_path(&self, id: &relicario_core::ItemId) -> PathBuf { self.root.join("items").join(format!("{}.enc", id.as_str())) } pub fn member_key_path(&self, id: &MemberId) -> PathBuf { self.root.join("keys").join(format!("{}.enc", id.as_str())) } pub fn members_path(&self) -> PathBuf { self.root.join("members.json") } pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") } pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") } pub fn load_meta(&self) -> Result { let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?; Ok(serde_json::from_str(&s).context("parse org.json")?) } pub fn load_members(&self) -> Result { let s = fs::read_to_string(self.members_path()).context("read members.json")?; Ok(serde_json::from_str(&s).context("parse members.json")?) } pub fn save_members(&self, members: &OrgMembers) -> Result<()> { let json = serde_json::to_string_pretty(members)?; atomic_write(&self.members_path(), json.as_bytes()) } pub fn load_collections(&self) -> Result { let s = fs::read_to_string(self.collections_path()).context("read collections.json")?; Ok(serde_json::from_str(&s).context("parse collections.json")?) } pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> { let json = serde_json::to_string_pretty(collections)?; atomic_write(&self.collections_path(), json.as_bytes()) } pub fn load_manifest(&self) -> Result { let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?; Ok(decrypt_org_manifest(&bytes, &self.org_key)?) } pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> { let bytes = encrypt_org_manifest(manifest, &self.org_key)?; atomic_write(&self.manifest_path(), &bytes) } /// Load members.json, find the caller's member entry by matching their device /// pubkey against all member pubkeys. Returns the matching member or bails. pub fn current_member(&self) -> Result { let device_pubkey = crate::device::current_device_pubkey()?; let members = self.load_members()?; members.members.into_iter() .find(|m| m.ed25519_pubkey.trim() == device_pubkey.trim()) .ok_or_else(|| anyhow::anyhow!( "your device key is not registered in this org — ask an admin to run `org add-member`" )) } } /// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value. pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result { if let Some(d) = dir_flag { return Ok(d.to_path_buf()); } if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") { return Ok(PathBuf::from(v)); } bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir ") } /// Open an org vault: locate the root, read members.json to find the caller's /// member entry, unwrap their keys/.enc to get the org master key. pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result { let root = org_dir(dir_flag)?; // Find caller's member entry by device pubkey let device_pubkey = crate::device::current_device_pubkey()?; let members_json = fs::read_to_string(root.join("members.json")) .context("read members.json — is this an org vault?")?; let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?; let member = members.members.iter() .find(|m| m.ed25519_pubkey.trim() == device_pubkey.trim()) .ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?; // Load this member's wrapped key blob let key_path = root.join("keys").join(format!("{}.enc", member.member_id.as_str())); let wrapped = fs::read(&key_path) .with_context(|| format!("read {}", key_path.display()))?; // Get device seed to unwrap let seed = crate::device::current_device_seed()?; let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?; Ok(UnlockedOrgVault { root, org_key }) } pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { let mut tmp = path.as_os_str().to_owned(); tmp.push(".tmp"); let tmp = PathBuf::from(tmp); fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?; fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?; Ok(()) } pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> { crate::helpers::git_run(root, args, context) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; use std::fs; fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) { let dir = TempDir::new().unwrap(); let root = dir.path().to_path_buf(); fs::create_dir_all(root.join("items")).unwrap(); fs::create_dir_all(root.join("keys")).unwrap(); let vault = UnlockedOrgVault { root, org_key: key }; (dir, vault) } #[test] fn unlocked_org_vault_paths() { let key = Zeroizing::new([0u8; 32]); let (dir, vault) = make_vault(key); let root = dir.path().to_path_buf(); assert_eq!(vault.manifest_path(), root.join("manifest.enc")); assert_eq!( vault.member_key_path(&MemberId("abc0def1abc0def1".into())), root.join("keys/abc0def1abc0def1.enc") ); } #[test] fn save_and_load_manifest() { let key = Zeroizing::new([0xAAu8; 32]); let (dir, vault) = make_vault(key); let _ = dir; // keep alive let mut m = OrgManifest::new(); m.entries.push(relicario_core::OrgManifestEntry { id: relicario_core::ItemId::new(), r#type: relicario_core::ItemType::SecureNote, title: "test".into(), tags: vec![], modified: 0, trashed_at: None, collection: "prod".into(), }); vault.save_manifest(&m).unwrap(); let loaded = vault.load_manifest().unwrap(); assert_eq!(loaded.entries.len(), 1); } #[test] fn save_and_load_members() { let key = Zeroizing::new([0u8; 32]); let (dir, vault) = make_vault(key); let root = dir.path().to_path_buf(); // need members.json location let _ = root; let members = OrgMembers::new(); vault.save_members(&members).unwrap(); let loaded = vault.load_members().unwrap(); assert_eq!(loaded.schema_version, 1); } } ``` - [ ] **Step 3: Wire into main.rs module declarations** In `crates/relicario-cli/src/main.rs`, add after the existing `mod session;` line: ```rust mod org_session; ``` - [ ] **Step 4: Run tests** ```bash cargo test -p relicario-cli org_session 2>&1 | tail -20 ``` Expected: all org_session tests pass. - [ ] **Step 5: Commit** ```bash git add crates/relicario-cli/src/org_session.rs crates/relicario-cli/src/main.rs git commit -m "feat(cli/org): UnlockedOrgVault session type" ``` > **Note:** `current_device_pubkey()` and `current_device_seed()` are referenced above. These need to be added to `crates/relicario-cli/src/device.rs` in Task 5. If `device.rs` already has a way to get the current device pubkey, use that. Otherwise, implement them in Task 5. --- ## [Dev-B] Task 5: Device seed/pubkey helpers + org commands module stub **Files:** - Modify: `crates/relicario-cli/src/device.rs` (or wherever device helpers live) - Create: `crates/relicario-cli/src/commands/org.rs` (stub) - Modify: `crates/relicario-cli/src/commands/mod.rs` - [ ] **Step 1: Check existing device module** ```bash grep -n "pubkey\|seed\|signing_key\|device" crates/relicario-cli/src/device.rs | head -30 ``` Look for existing functions that expose the device's ed25519 signing key or public key. If `current_device_pubkey()` already exists in some form, adapt. If not, proceed to Step 2. - [ ] **Step 2: Add device seed + pubkey helpers** In `crates/relicario-cli/src/device.rs` (or create it if the file doesn't exist with these helpers), add: ```rust /// Read the current device's ed25519 seed from the device key file. /// The key file location is RELICARIO_DEVICE_KEY env var or ~/.config/relicario/device.key. pub fn current_device_seed() -> anyhow::Result> { let path = device_key_path()?; let pem = std::fs::read_to_string(&path) .with_context(|| format!("read device key {}", path.display()))?; let private_key = ssh_key::PrivateKey::from_openssh(&pem) .map_err(|e| anyhow::anyhow!("parse device key: {e}"))?; let ed = private_key.key_data().ed25519() .ok_or_else(|| anyhow::anyhow!("device key is not ed25519"))?; let seed_bytes = ed.private.as_ref(); if seed_bytes.len() != 32 { anyhow::bail!("ed25519 seed has wrong length: {}", seed_bytes.len()); } let mut seed = zeroize::Zeroizing::new([0u8; 32]); seed.copy_from_slice(seed_bytes); Ok(seed) } /// Read the current device's ed25519 public key in OpenSSH format. pub fn current_device_pubkey() -> anyhow::Result { let path = device_key_path()?; let pem = std::fs::read_to_string(&path) .with_context(|| format!("read device key {}", path.display()))?; let private_key = ssh_key::PrivateKey::from_openssh(&pem) .map_err(|e| anyhow::anyhow!("parse device key: {e}"))?; Ok(private_key.public_key().to_openssh() .map_err(|e| anyhow::anyhow!("serialize pubkey: {e}"))?) } fn device_key_path() -> anyhow::Result { if let Ok(p) = std::env::var("RELICARIO_DEVICE_KEY") { return Ok(std::path::PathBuf::from(p)); } let home = std::env::var("HOME") .map_err(|_| anyhow::anyhow!("HOME not set"))?; Ok(std::path::PathBuf::from(home) .join(".config/relicario/device.key")) } ``` > **Note:** Check how the existing `device.rs` in `relicario-cli` generates/reads device keys. Adapt the path logic to match. The existing `crates/relicario-cli/src/device.rs` may already have a `device_key_path()` — don't duplicate it, just add the two public helpers if absent. - [ ] **Step 3: Create org commands stub** Create `crates/relicario-cli/src/commands/org.rs`: ```rust //! `relicario org` subcommands for multi-user org vault management. use anyhow::Result; pub fn run_init(_dir: &std::path::Path, _name: &str) -> Result<()> { todo!("org init") } ``` Add to `crates/relicario-cli/src/commands/mod.rs`: ```rust pub mod org; ``` - [ ] **Step 4: Verify compile** ```bash cargo check -p relicario-cli ``` Expected: clean (todo! is fine at compile time). - [ ] **Step 5: Commit** ```bash git add crates/relicario-cli/src/device.rs crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/commands/mod.rs git commit -m "feat(cli/org): device seed/pubkey helpers + org commands stub" ``` --- ## [Dev-B] Task 6: org init command **Files:** - Modify: `crates/relicario-cli/src/commands/org.rs` `org init` creates the org directory structure, generates the org master key, wraps it to the caller's device key, writes all initial files, runs `git init` + first commit. - [ ] **Step 1: Write failing integration test** Create `crates/relicario-cli/tests/org_init.rs`: ```rust use std::fs; use tempfile::TempDir; fn run(args: &[&str]) -> std::process::Output { std::process::Command::new(env!("CARGO_BIN_EXE_relicario")) .args(args) .output() .expect("run relicario") } #[test] #[ignore] // requires a device key on disk; run manually fn org_init_creates_expected_files() { let dir = TempDir::new().unwrap(); let path = dir.path().to_str().unwrap(); let out = run(&["org", "init", "--dir", path, "--name", "Test Org"]); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); assert!(dir.path().join("org.json").exists()); assert!(dir.path().join("members.json").exists()); assert!(dir.path().join("collections.json").exists()); assert!(dir.path().join("manifest.enc").exists()); assert!(dir.path().join(".git").exists()); } ``` ```bash cargo test -p relicario-cli --test org_init 2>&1 | tail -5 ``` Expected: test compiles and is skipped (ignored). - [ ] **Step 2: Implement org init** Replace `run_init` stub in `commands/org.rs`: ```rust 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 + initial commit crate::helpers::git_run(dir, &["init"], "git init")?; crate::helpers::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::helpers::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()) } ``` - [ ] **Step 3: Build and smoke-test manually** ```bash cargo build -p relicario-cli 2>&1 | tail -10 ``` Expected: clean build. - [ ] **Step 4: Commit** ```bash git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/tests/org_init.rs git commit -m "feat(cli/org): org init command" ``` --- ## [Dev-B] Task 7: org add-member, remove-member, set-role **Files:** - Modify: `crates/relicario-cli/src/commands/org.rs` All three commands share the same open-vault → edit members.json → commit pattern. - [ ] **Step 1: Write failing unit tests** Add to `commands/org.rs`: ```rust #[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); } } ``` ```bash cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5 ``` Expected: PASS (pure logic test, no I/O). - [ ] **Step 2: Implement add-member** Add to `commands/org.rs`: ```rust 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"); } 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(()) } ``` - [ ] **Step 3: Implement remove-member** ```rust 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(()) } ``` - [ ] **Step 4: Implement set-role** ```rust 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 { 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()), } } ``` - [ ] **Step 5: Compile check** ```bash cargo build -p relicario-cli 2>&1 | tail -10 ``` Expected: clean build. - [ ] **Step 6: Commit** ```bash git add crates/relicario-cli/src/commands/org.rs git commit -m "feat(cli/org): add-member, remove-member, set-role commands" ``` --- ## [Dev-B] Task 8: org create-collection, grant, revoke **Files:** - Modify: `crates/relicario-cli/src/commands/org.rs` - [ ] **Step 1: Write failing test** Add to the `tests` block in `commands/org.rs`: ```rust #[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())); } ``` ```bash cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5 ``` Expected: all pass. - [ ] **Step 2: Implement create-collection** ```rust 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(()) } ``` - [ ] **Step 3: Implement grant** ```rust 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(()) } ``` - [ ] **Step 4: Implement revoke** ```rust 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(()) } ``` - [ ] **Step 5: Compile + commit** ```bash cargo build -p relicario-cli 2>&1 | tail -5 git add crates/relicario-cli/src/commands/org.rs git commit -m "feat(cli/org): create-collection, grant, revoke commands" ``` --- ## [Dev-B] Task 9: org rotate-key **Files:** - Modify: `crates/relicario-cli/src/commands/org.rs` `rotate-key` generates a new org master key, re-wraps it for all current members, re-encrypts the manifest (item blobs are NOT re-encrypted), and commits. - [ ] **Step 1: Write failing test** Add to tests block: ```rust #[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); } ``` ```bash cargo test -p relicario-cli commands::org::tests::new_key_differs 2>&1 | tail -5 ``` Expected: PASS. - [ ] **Step 2: Implement rotate-key** ```rust pub fn run_rotate_key(dir: &Path) -> Result<()> { // Pull latest state first to detect concurrent rotations let pull_result = crate::helpers::git_run(dir, &["pull", "--rebase"], "git pull --rebase"); if let Err(e) = pull_result { // Non-fatal if no remote configured (local-only orgs) eprintln!("Note: git pull --rebase failed ({}). Proceeding with local state.", e); } 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 for all current members 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); fs::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 manifest with new key (items do not need re-encryption) 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.", members.members.len()); Ok(()) } ``` - [ ] **Step 3: Build + commit** ```bash cargo build -p relicario-cli 2>&1 | tail -5 git add crates/relicario-cli/src/commands/org.rs git commit -m "feat(cli/org): rotate-key command" ``` --- ## [Dev-B] Task 10: org status + org audit **Files:** - Modify: `crates/relicario-cli/src/commands/org.rs` - [ ] **Step 1: Implement status** ```rust 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(()) } ``` - [ ] **Step 2: Write failing test for audit trailer parsing** Add to tests block: ```rust #[test] fn parse_trailers_extracts_relicario_fields() { let raw = "Relicario-Actor: alice \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")); assert_eq!(event.actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2")); } ``` ```bash cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5 ``` Expected: FAIL — `parse_trailer_block` not defined. - [ ] **Step 3: Implement audit** ```rust #[derive(Debug, serde::Serialize)] pub struct AuditEvent { pub commit: String, pub timestamp: String, pub actor_name: Option, pub actor_id: Option, pub action: Option, pub collection: Option, pub item_id: Option, pub device_id: Option, } 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, action: None, collection: None, item_id: None, device_id: None, }; for line in trailers.lines() { if let Some(rest) = line.strip_prefix("Relicario-Actor: ") { // Format: "Name " if let (Some(lt), Some(gt)) = (rest.rfind('<'), rest.rfind('>')) { ev.actor_name = Some(rest[..lt].trim().to_string()); ev.actor_id = Some(rest[lt+1..gt].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 } pub fn run_audit( dir: &Path, since: Option<&str>, member_filter: Option<&str>, collection_filter: Option<&str>, action_filter: Option<&str>, json: bool, ) -> Result<()> { let root = crate::org_session::org_dir(Some(dir))?; // git log with separator-delimited format let sep = "\x1F"; // ASCII unit separator — won't appear in messages let fmt = format!("{sep}%H{sep}%aI{sep}%(trailers)"); let mut args = vec!["log", &format!("--format={fmt}")]; let since_arg; if let Some(s) = since { since_arg = format!("--since={s}"); args.push(&since_arg); } let output = std::process::Command::new("git") .args(&args) .current_dir(&root) .output() .context("git log")?; let log = String::from_utf8_lossy(&output.stdout); let mut events: Vec = Vec::new(); for chunk in log.split(sep).collect::>().chunks(4) { if chunk.len() < 4 { continue; } let (_marker, commit, ts, trailers) = (chunk[0], chunk[1], chunk[2], chunk[3]); if commit.trim().is_empty() { continue; } let ev = parse_trailer_block(commit.trim(), ts.trim(), trailers); if ev.action.is_none() { continue; } // not an org commit if let Some(mid) = member_filter { 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} {:<15}", "COMMIT", "TIMESTAMP", "ACTION", "ACTOR"); for ev in &events { println!("{:<44} {:<26} {:<20} {}", ev.commit, ev.timestamp, ev.action.as_deref().unwrap_or("-"), ev.actor_name.as_deref().unwrap_or("-"), ); } } Ok(()) } ``` - [ ] **Step 4: Run the trailer test** ```bash cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5 ``` Expected: PASS. - [ ] **Step 5: Build + commit** ```bash cargo build -p relicario-cli 2>&1 | tail -5 git add crates/relicario-cli/src/commands/org.rs git commit -m "feat(cli/org): status and audit commands" ``` --- ## [Dev-B] Task 11: Wire Commands::Org into main.rs **Files:** - Modify: `crates/relicario-cli/src/main.rs` - [ ] **Step 1: Read the current Commands enum top** ```bash grep -n "Subcommand\|Commands\|enum\|Org" crates/relicario-cli/src/main.rs | head -40 ``` Look for where the `Commands` enum and the `match cli.command` dispatch live. - [ ] **Step 2: Add Org subcommand to the Commands enum** In the `Commands` enum, add (after the last existing variant): ```rust /// Manage a multi-user org vault. Org { /// Path to the org vault directory (overrides RELICARIO_ORG_DIR). #[arg(long, global = true)] dir: Option, #[command(subcommand)] subcommand: OrgCommands, }, ``` Add the `OrgCommands` enum (top-level, after the `Commands` enum): ```rust #[derive(Subcommand)] enum OrgCommands { /// Create a new org vault. Init { #[arg(long)] name: String, }, /// 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, /// Show org members and collections. Status, /// Query the org audit log. Audit { #[arg(long)] since: Option, #[arg(long)] member: Option, #[arg(long)] collection: Option, #[arg(long)] action: Option, #[arg(long)] json: bool, }, } ``` - [ ] **Step 3: Add dispatch arm in main()** In the `match cli.command { ... }` block, add: ```rust Commands::Org { dir, subcommand } => { let dir_path = dir.as_deref(); match subcommand { OrgCommands::Init { name } => { let d = dir_path.ok_or_else(|| anyhow::anyhow!("--dir required for org init"))?; commands::org::run_init(d, &name)?; } 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::Status => { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_status(&d)?; } OrgCommands::Audit { since, member, collection, action, json } => { 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(), json)?; } } } ``` Add the `parse_org_role` helper in `main.rs`: ```rust fn parse_org_role(s: &str) -> anyhow::Result { 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"), } } ``` - [ ] **Step 4: Build and test help output** ```bash cargo build -p relicario-cli 2>&1 | tail -10 ./target/debug/relicario org --help ./target/debug/relicario org init --help ``` Expected: clean build, help text for all org subcommands. - [ ] **Step 5: Commit** ```bash git add crates/relicario-cli/src/main.rs git commit -m "feat(cli): wire Commands::Org subcommand into main.rs" ``` --- ## [Dev-C] Task 12: Pre-receive hook org extension in relicario-server **Files:** - Modify: `crates/relicario-server/src/main.rs` The server gains a `verify-org-commit` subcommand that validates: 1. Only owners/admins wrote to `members.json`, `collections.json`, `org.json` 2. Schema version did not decrease 3. (Warning) If a `member-remove` commit happened without a following `key-rotate`, emit a warning on the next push - [ ] **Step 1: Write failing test** Create `crates/relicario-server/tests/org_hook.rs`: ```rust #[test] fn parse_changed_paths_detects_members_json() { let paths = vec!["members.json", "items/abc123.enc"]; assert!(paths.iter().any(|p| *p == "members.json")); } ``` ```bash cargo test -p relicario-server 2>&1 | tail -5 ``` Expected: PASS (trivial smoke test to verify the test harness works). - [ ] **Step 2: Add VerifyOrgCommit subcommand** In `crates/relicario-server/src/main.rs`, add to the `Commands` enum: ```rust /// Verify that a commit to an org vault respects role-based path authorization. VerifyOrgCommit { /// The commit SHA to verify. commit: String, }, /// Generate an org pre-receive hook script. GenerateOrgHook, ``` Add dispatch in `main()`: ```rust Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit), Commands::GenerateOrgHook => generate_org_hook(), ``` - [ ] **Step 3: Implement verify_org_commit** ```rust fn verify_org_commit(commit: &str) -> Result<()> { // Read members.json from the commit tree let members_json = match git_show(commit, "members.json") { Ok(s) => s, Err(_) => { eprintln!("OK: org commit {commit} (bootstrap - no members.json)"); return Ok(()); } }; let members: relicario_core::OrgMembers = serde_json::from_str(&members_json) .context("parse members.json")?; members.validate().context("members.json schema invalid")?; // Get changed paths in this commit vs its parent let parent_output = Command::new("git") .args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit]) .output() .context("git diff-tree")?; let changed_paths: Vec = String::from_utf8_lossy(&parent_output.stdout) .lines() .map(|l| l.trim().to_string()) .collect(); // Protected paths: only owner/admin may write these let protected = ["members.json", "collections.json", "org.json"]; let touches_protected = changed_paths.iter().any(|p| protected.contains(&p.as_str())); if touches_protected { // Find the signing key fingerprint of this commit let fp = commit_signing_fingerprint(commit)?; // Look up which member has this fingerprint let signing_member = members.members.iter().find(|m| { relicario_core::fingerprint(&m.ed25519_pubkey) .ok() .as_deref() == Some(&fp) }); match signing_member { None => { eprintln!("REJECT: org commit {commit} — signer not in members.json"); std::process::exit(1); } Some(m) if !m.role.can_manage_members() => { eprintln!( "REJECT: org commit {commit} — member '{}' (role {:?}) cannot write protected files", m.display_name, m.role ); std::process::exit(1); } Some(m) => { eprintln!("OK: org commit {commit} — protected-path write by '{}' ({:?})", m.display_name, m.role); } } } else { eprintln!("OK: org commit {commit} (no protected paths touched)"); } // Rotation warning: if members.json changed but keys/ directory did NOT change, // emit a warning (member removed without rotating key) let members_changed = changed_paths.iter().any(|p| p == "members.json"); let keys_changed = changed_paths.iter().any(|p| p.starts_with("keys/")); if members_changed && !keys_changed { eprintln!("WARN: org commit {commit} — members.json changed but no key rotation detected"); eprintln!("WARN: run `relicario org rotate-key` to complete member revocation"); } Ok(()) } fn commit_signing_fingerprint(commit: &str) -> Result { let output = Command::new("git") .args(["show", "-s", "--format=%GF", commit]) .output() .context("git show --format=%GF")?; let fp = String::from_utf8_lossy(&output.stdout).trim().to_string(); if fp.is_empty() { anyhow::bail!("commit {commit} has no GPG/SSH signature fingerprint"); } Ok(fp) } fn generate_org_hook() -> Result<()> { print!(r#"#!/bin/bash # Relicario org pre-receive hook while read oldrev newrev refname; do [ "$newrev" = "0000000000000000000000000000000000000000" ] && continue if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then commits=$(git rev-list "$newrev") else commits=$(git rev-list "$oldrev..$newrev") fi for commit in $commits; do relicario-server verify-org-commit "$commit" || exit 1 done done "#); Ok(()) } ``` - [ ] **Step 4: Build relicario-server** ```bash cargo build -p relicario-server 2>&1 | tail -10 ``` Expected: clean build. - [ ] **Step 5: Commit** ```bash git add crates/relicario-server/src/main.rs crates/relicario-server/tests/org_hook.rs git commit -m "feat(server): verify-org-commit + generate-org-hook subcommands" ``` --- ## Task 13: Full org lifecycle integration test **Files:** - Create: `crates/relicario-core/tests/org.rs` This test exercises the complete core-level org flow without requiring a device key on disk. - [ ] **Step 1: Write the integration test** Create `crates/relicario-core/tests/org.rs`: ```rust use relicario_core::{ generate_org_key, wrap_org_key, unwrap_org_key, encrypt_org_manifest, decrypt_org_manifest, OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgRole, MemberId, ItemId, }; use relicario_core::item_types::ItemType; use rand::rngs::OsRng; use rand::RngCore; use zeroize::Zeroizing; fn make_member_keypair() -> (Zeroizing<[u8; 32]>, String) { let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); let pubkey_openssh = ssh_key::PrivateKey::from(signing_key) .public_key() .to_openssh() .expect("openssh"); (Zeroizing::new(seed), pubkey_openssh) } #[test] fn org_key_wrap_unwrap_round_trip() { let (seed, pubkey) = make_member_keypair(); let org_key = generate_org_key(); let wrapped = wrap_org_key(&org_key, &pubkey).expect("wrap"); let unwrapped = unwrap_org_key(&wrapped, &seed).expect("unwrap"); assert_eq!(*org_key, *unwrapped); } #[test] fn revoked_member_cannot_decrypt_after_rotation() { // Alice and Bob both get access let (alice_seed, alice_pubkey) = make_member_keypair(); let (_bob_seed, bob_pubkey) = make_member_keypair(); let org_key = generate_org_key(); let _alice_wrapped = wrap_org_key(&org_key, &alice_pubkey).expect("wrap alice"); let _bob_wrapped = wrap_org_key(&org_key, &bob_pubkey).expect("wrap bob"); // Rotate: new key, only Bob gets re-wrapped let new_org_key = generate_org_key(); let new_bob_wrapped = wrap_org_key(&new_org_key, &bob_pubkey).expect("wrap bob new"); // Alice tries to use old org_key — she can still decrypt old items, // but new_bob_wrapped was encrypted with new_org_key, not org_key. // Verify: unwrapping new_bob_wrapped with Alice's seed fails. let result = unwrap_org_key(&new_bob_wrapped, &alice_seed); assert!(result.is_err(), "Alice should not be able to unwrap Bob's new key blob"); } #[test] fn org_manifest_filter_restricts_to_granted_collections() { let mut manifest = OrgManifest::new(); for (title, collection) in &[("A", "prod"), ("B", "dev"), ("C", "prod")] { manifest.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: ItemType::SecureNote, title: title.to_string(), tags: vec![], modified: 0, trashed_at: None, collection: collection.to_string(), }); } let member = OrgMember { member_id: MemberId::new(), display_name: "Alice".into(), role: OrgRole::Member, ed25519_pubkey: String::new(), collections: vec!["prod".into()], added_at: 0, added_by: MemberId::new(), }; let filtered = manifest.filter_for_member(&member); assert_eq!(filtered.entries.len(), 2); assert!(filtered.entries.iter().all(|e| e.collection == "prod")); } #[test] fn org_manifest_encrypt_decrypt_round_trip() { let key = generate_org_key(); let mut manifest = OrgManifest::new(); manifest.entries.push(OrgManifestEntry { id: ItemId::new(), r#type: ItemType::Login, title: "GitHub".into(), tags: vec!["work".into()], modified: 1748000000, trashed_at: None, collection: "eng-tools".into(), }); let encrypted = encrypt_org_manifest(&manifest, &key).expect("encrypt"); let decrypted = decrypt_org_manifest(&encrypted, &key).expect("decrypt"); assert_eq!(decrypted.entries.len(), 1); assert_eq!(decrypted.entries[0].title, "GitHub"); assert_eq!(decrypted.entries[0].collection, "eng-tools"); } #[test] fn members_validation_rejects_invalid_id() { let mut members = OrgMembers::new(); members.members.push(OrgMember { member_id: MemberId("not-hex-lol!!".to_string()), display_name: "Bad".into(), role: OrgRole::Member, ed25519_pubkey: String::new(), collections: vec![], added_at: 0, added_by: MemberId::new(), }); assert!(members.validate().is_err()); } ``` - [ ] **Step 2: Add ed25519-dalek and ssh-key as dev-dependencies in relicario-core** In `crates/relicario-core/Cargo.toml`, add to `[dev-dependencies]`: ```toml ed25519-dalek = { version = "2", features = ["rand_core"] } ssh-key = { version = "0.6", features = ["ed25519", "std"] } ``` (These are already in `[dependencies]` — just make sure they're also available in tests. If they're already in `[dependencies]`, they're already available; skip this step.) - [ ] **Step 3: Run all org integration tests** ```bash cargo test -p relicario-core --test org 2>&1 | tail -20 ``` Expected: all 5 tests pass. - [ ] **Step 4: Run the full test suite** ```bash cargo test 2>&1 | tail -20 ``` Expected: all tests pass across all crates. - [ ] **Step 5: Final commit** ```bash git add crates/relicario-core/tests/org.rs crates/relicario-core/Cargo.toml git commit -m "test(core/org): full org lifecycle integration tests" ``` --- ## Self-Review **Spec coverage check:** | Spec section | Covered by task(s) | |---|---| | Org master key (256-bit random, wrapped per-member) | Tasks 2, 3, 13 | | `org.json`, `members.json`, `collections.json` data model | Tasks 2, 6 | | `keys/.enc` per-member key blob | Tasks 3, 6, 7 | | `manifest.enc` + `items/*.enc` same format as personal | Tasks 3, 4 | | X25519 key wrapping ECIES | Task 2 | | Roles: owner/admin/member | Task 2 | | Collection access grants in `members.json` | Tasks 2, 8 | | Manifest filtering per member grants | Tasks 2, 13 | | `org init` | Task 6 | | `org add-member` | Task 7 | | `org remove-member` (+ rotation warning) | Task 7 | | `org set-role` | Task 7 | | `org create-collection` | Task 8 | | `org grant` / `org revoke` | Task 8 | | `org rotate-key` (pull --rebase, re-wrap, re-encrypt manifest) | Task 9 | | `org status` | Task 10 | | `org audit` (trailers, --format json) | Task 10 | | Pre-receive hook: protected-path enforcement | Task 12 | | Pre-receive hook: rotation warning | Task 12 | | `UnlockedOrgVault` org master key in Zeroizing session | Task 4 | | Extension integration | **Not in this plan — Plan B** | | LDAP/SAML sync | **Not in this plan — Phase 2 spec** | **Placeholder scan:** No TBD, TODO, or "similar to Task N" references found. **Type consistency:** `MemberId`, `OrgRole`, `OrgMembers`, `OrgMember`, `OrgManifest`, `OrgManifestEntry`, `CollectionDef`, `OrgCollections`, `OrgMeta` all defined in Task 2 and used consistently in Tasks 4, 6–12. `wrap_org_key` / `unwrap_org_key` / `generate_org_key` defined in Task 2, used in Tasks 6, 7, 9, 13.