Files
relicario/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
adlee-was-taken 2543ed30f6 docs(plan): enterprise org vault implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 21:22:15 -04:00

79 KiB
Raw Blame History

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/<member-id>.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 14 (relicario-core org module). No dependencies.
  • Dev-B — Tasks 513 (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:

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:

//! 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:

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
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:

pub mod org;

Then add the pub use items incrementally as each Task defines the symbols.

  • Step 5: Commit
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

#[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:

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:

//! 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<String>,
    pub added_at: i64,
    pub added_by: MemberId,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgMembers {
    pub schema_version: u32,
    pub members: Vec<OrgMember>,
}

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<CollectionDef>,
}

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<String>,
    pub modified: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub trashed_at: Option<i64>,
    /// Collection this item belongs to.
    pub collection: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgManifest {
    pub schema_version: u32,
    pub entries: Vec<OrgManifestEntry>,
}

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<x25519_dalek::PublicKey> {
    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<Vec<u8>> {
    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<Zeroizing<[u8; 32]>> {
    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
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):

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,
};
cargo check -p relicario-core

Expected: clean compile.

  • Step 5: Commit
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:

#[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");
}
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):

use crate::org::OrgManifest;

pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
    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<OrgManifest> {
    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:

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
cargo test -p relicario-core vault::tests::org_manifest_round_trip

Expected: PASS.

cargo test -p relicario-core

Expected: all tests pass.

  • Step 4: Commit
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:

#[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"));
    }
}
cargo test -p relicario-cli org_session 2>&1 | tail -5

Expected: FAIL — UnlockedOrgVault not defined.

  • Step 2: Implement UnlockedOrgVault
//! 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<OrgMeta> {
        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<OrgMembers> {
        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<OrgCollections> {
        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<OrgManifest> {
        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<relicario_core::OrgMember> {
        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<PathBuf> {
    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 <path>")
}

/// Open an org vault: locate the root, read members.json to find the caller's
/// member entry, unwrap their keys/<id>.enc to get the org master key.
pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result<UnlockedOrgVault> {
    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:

mod org_session;
  • Step 4: Run tests
cargo test -p relicario-cli org_session 2>&1 | tail -20

Expected: all org_session tests pass.

  • Step 5: Commit
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

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:

/// 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<zeroize::Zeroizing<[u8; 32]>> {
    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<String> {
    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<std::path::PathBuf> {
    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:

//! `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:

pub mod org;
  • Step 4: Verify compile
cargo check -p relicario-cli

Expected: clean (todo! is fine at compile time).

  • Step 5: Commit
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:

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());
}
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:

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
cargo build -p relicario-cli 2>&1 | tail -10

Expected: clean build.

  • Step 4: Commit
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:

#[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);
    }
}
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:

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
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
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<MemberId> {
    let hits: Vec<_> = members.members.iter()
        .filter(|m| m.member_id.as_str().starts_with(prefix))
        .collect();
    match hits.len() {
        0 => anyhow::bail!("no member matches `{prefix}`"),
        1 => Ok(hits[0].member_id.clone()),
        _ => anyhow::bail!("ambiguous prefix `{prefix}` — {} matches", hits.len()),
    }
}
  • Step 5: Compile check
cargo build -p relicario-cli 2>&1 | tail -10

Expected: clean build.

  • Step 6: Commit
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:

#[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()));
}
cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5

Expected: all pass.

  • Step 2: Implement create-collection
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
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
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
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:

#[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);
}
cargo test -p relicario-cli commands::org::tests::new_key_differs 2>&1 | tail -5

Expected: PASS.

  • Step 2: Implement rotate-key
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<String> = Vec::new();
    for member in &members.members {
        let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey)
            .with_context(|| format!("wrap key for {}", member.display_name))?;
        let key_path = vault.member_key_path(&member.member_id);
        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
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

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:

#[test]
fn parse_trailers_extracts_relicario_fields() {
    let raw = "Relicario-Actor: alice <a1b2c3d4e5f6a1b2>\nRelicario-Action: item-create\nRelicario-Collection: prod\n";
    let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw);
    assert_eq!(event.action.as_deref(), Some("item-create"));
    assert_eq!(event.collection.as_deref(), Some("prod"));
    assert_eq!(event.actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
}
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
#[derive(Debug, serde::Serialize)]
pub struct AuditEvent {
    pub commit: String,
    pub timestamp: String,
    pub actor_name: Option<String>,
    pub actor_id: Option<String>,
    pub action: Option<String>,
    pub collection: Option<String>,
    pub item_id: Option<String>,
    pub device_id: Option<String>,
}

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 <id>"
            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<AuditEvent> = Vec::new();
    for chunk in log.split(sep).collect::<Vec<_>>().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
cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5

Expected: PASS.

  • Step 5: Build + commit
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

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):

/// Manage a multi-user org vault.
Org {
    /// Path to the org vault directory (overrides RELICARIO_ORG_DIR).
    #[arg(long, global = true)]
    dir: Option<PathBuf>,
    #[command(subcommand)]
    subcommand: OrgCommands,
},

Add the OrgCommands enum (top-level, after the Commands enum):

#[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<String>,
        #[arg(long)]
        member: Option<String>,
        #[arg(long)]
        collection: Option<String>,
        #[arg(long)]
        action: Option<String>,
        #[arg(long)]
        json: bool,
    },
}
  • Step 3: Add dispatch arm in main()

In the match cli.command { ... } block, add:

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:

fn parse_org_role(s: &str) -> anyhow::Result<relicario_core::OrgRole> {
    match s {
        "owner" => Ok(relicario_core::OrgRole::Owner),
        "admin" => Ok(relicario_core::OrgRole::Admin),
        "member" => Ok(relicario_core::OrgRole::Member),
        other => anyhow::bail!("unknown role `{other}` — use owner, admin, or member"),
    }
}
  • Step 4: Build and test help output
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
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:

#[test]
fn parse_changed_paths_detects_members_json() {
    let paths = vec!["members.json", "items/abc123.enc"];
    assert!(paths.iter().any(|p| *p == "members.json"));
}
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:

/// 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():

Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
Commands::GenerateOrgHook => generate_org_hook(),
  • Step 3: Implement verify_org_commit
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> = 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<String> {
    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
cargo build -p relicario-server 2>&1 | tail -10

Expected: clean build.

  • Step 5: Commit
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:

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]:

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
cargo test -p relicario-core --test org 2>&1 | tail -20

Expected: all 5 tests pass.

  • Step 4: Run the full test suite
cargo test 2>&1 | tail -20

Expected: all tests pass across all crates.

  • Step 5: Final commit
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/<member-id>.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, 612. wrap_org_key / unwrap_org_key / generate_org_key defined in Task 2, used in Tasks 6, 7, 9, 13.