79 KiB
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 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:
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()andcurrent_device_seed()are referenced above. These need to be added tocrates/relicario-cli/src/device.rsin Task 5. Ifdevice.rsalready 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.rsinrelicario-cligenerates/reads device keys. Adapt the path logic to match. The existingcrates/relicario-cli/src/device.rsmay already have adevice_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:
- Only owners/admins wrote to
members.json,collections.json,org.json - Schema version did not decrease
- (Warning) If a
member-removecommit happened without a followingkey-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, 6–12. wrap_org_key / unwrap_org_key / generate_org_key defined in Task 2, used in Tasks 6, 7, 9, 13.