diff --git a/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md b/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md new file mode 100644 index 0000000..972fc27 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md @@ -0,0 +1,2468 @@ +# 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.