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

2469 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```toml
x25519-dalek = { version = "2", features = ["static_secrets"] }
```
- [ ] **Step 2: Create org.rs stub**
Create `crates/relicario-core/src/org.rs` with just a module-level comment:
```rust
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
```
- [ ] **Step 3: Wire into lib.rs**
In `crates/relicario-core/src/lib.rs`, add after the `device` module block:
```rust
pub mod org;
pub use org::{
CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest, OrgManifestEntry,
OrgMember, OrgMembers, OrgMeta, OrgRole,
generate_org_key, wrap_org_key, unwrap_org_key,
};
```
- [ ] **Step 4: Verify it compiles**
```bash
cargo check -p relicario-core
```
Expected: compiles (stub is empty, re-exports will fail — that's fine until Task 2 defines them).
Actually at this step the re-exports will fail. Wire the `pub mod org;` line only, no `pub use` yet:
```rust
pub mod org;
```
Then add the `pub use` items incrementally as each Task defines the symbols.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-core/Cargo.toml crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): add x25519-dalek dep + stub org module"
```
---
## [Dev-A] Task 2: Org types — IDs, members, collections, org meta
**Files:**
- Modify: `crates/relicario-core/src/org.rs`
- [ ] **Step 1: Write failing test for MemberId format**
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn member_id_is_16_hex_chars() {
let id = MemberId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn member_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..1_000 {
assert!(seen.insert(MemberId::new().0));
}
}
#[test]
fn org_id_is_16_hex_chars() {
let id = OrgId::new();
assert_eq!(id.0.len(), 16);
}
}
```
Add this at the bottom of `org.rs`, run:
```bash
cargo test -p relicario-core org::tests::member_id_is_16_hex_chars 2>&1 | tail -5
```
Expected: FAIL — `MemberId` not defined.
- [ ] **Step 2: Implement all org types**
Replace the stub `org.rs` with:
```rust
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::ids::ItemId;
use crate::item_types::ItemType;
// ── IDs ──────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct OrgId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MemberId(pub String);
impl OrgId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl Default for OrgId {
fn default() -> Self { Self::new() }
}
impl MemberId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
pub fn is_valid(&self) -> bool {
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
}
}
impl Default for MemberId {
fn default() -> Self { Self::new() }
}
// ── Roles ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OrgRole {
Owner,
Admin,
Member,
}
impl OrgRole {
pub fn can_manage_members(&self) -> bool {
matches!(self, OrgRole::Owner | OrgRole::Admin)
}
pub fn can_manage_owners(&self) -> bool {
matches!(self, OrgRole::Owner)
}
}
// ── Members ──────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgMember {
pub member_id: MemberId,
pub display_name: String,
pub role: OrgRole,
/// SSH public key string (openssh format: "ssh-ed25519 AAAA...")
pub ed25519_pubkey: String,
/// Collection slugs this member can access.
#[serde(default)]
pub collections: Vec<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**
```bash
cargo test -p relicario-core org:: 2>&1 | tail -20
```
Expected: all org tests pass.
- [ ] **Step 4: Update lib.rs re-exports**
Add to `crates/relicario-core/src/lib.rs` (replace the `pub mod org;` stub line):
```rust
pub mod org;
pub use org::{
generate_org_key, unwrap_org_key, wrap_org_key,
CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest,
OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole,
};
```
```bash
cargo check -p relicario-core
```
Expected: clean compile.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): org types, manifest, and X25519 key wrap/unwrap"
```
---
## [Dev-A] Task 3: Org manifest vault wrappers
**Files:**
- Modify: `crates/relicario-core/src/vault.rs`
- [ ] **Step 1: Write failing tests**
Add to `crates/relicario-core/src/vault.rs` tests block:
```rust
#[test]
fn org_manifest_round_trip() {
use crate::org::{OrgManifest, OrgManifestEntry, MemberId};
use crate::ids::ItemId;
use crate::item_types::ItemType;
let mut m = OrgManifest::new();
m.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: ItemType::SecureNote,
title: "test".into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: "prod".into(),
});
let key = key();
let bytes = encrypt_org_manifest(&m, &key).unwrap();
let decoded = decrypt_org_manifest(&bytes, &key).unwrap();
assert_eq!(decoded.entries.len(), 1);
assert_eq!(decoded.entries[0].collection, "prod");
}
```
```bash
cargo test -p relicario-core vault::tests::org_manifest_round_trip 2>&1 | tail -5
```
Expected: FAIL — `encrypt_org_manifest` not defined.
- [ ] **Step 2: Add org manifest wrappers to vault.rs**
Add to `crates/relicario-core/src/vault.rs` (after the existing `decrypt_settings` function):
```rust
use crate::org::OrgManifest;
pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result<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:
```rust
pub use vault::{
decrypt_item, decrypt_manifest, decrypt_org_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_org_manifest, encrypt_settings,
};
```
- [ ] **Step 3: Run tests**
```bash
cargo test -p relicario-core vault::tests::org_manifest_round_trip
```
Expected: PASS.
```bash
cargo test -p relicario-core
```
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add crates/relicario-core/src/vault.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): encrypt/decrypt_org_manifest vault wrappers"
```
---
## [Dev-B] Task 4: UnlockedOrgVault session type
**Files:**
- Create: `crates/relicario-cli/src/org_session.rs`
- [ ] **Step 1: Write failing test**
Create `crates/relicario-cli/src/org_session.rs` with just the test:
```rust
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
fn make_org_dir() -> TempDir {
let dir = TempDir::new().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("items")).unwrap();
fs::create_dir_all(root.join("keys")).unwrap();
dir
}
#[test]
fn unlocked_org_vault_paths() {
let dir = make_org_dir();
let root = dir.path().to_path_buf();
let key = zeroize::Zeroizing::new([0u8; 32]);
let vault = UnlockedOrgVault { root: root.clone(), org_key: key };
assert_eq!(vault.manifest_path(), root.join("manifest.enc"));
assert_eq!(vault.member_key_path(&relicario_core::MemberId("abc0def1abc0def1".into())),
root.join("keys/abc0def1abc0def1.enc"));
}
}
```
```bash
cargo test -p relicario-cli org_session 2>&1 | tail -5
```
Expected: FAIL — `UnlockedOrgVault` not defined.
- [ ] **Step 2: Implement UnlockedOrgVault**
```rust
//! Unlocked org vault session: holds the org master key for the duration of a
//! CLI invocation.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;
use relicario_core::{
decrypt_org_manifest, encrypt_org_manifest,
MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
};
pub struct UnlockedOrgVault {
pub root: PathBuf,
pub org_key: Zeroizing<[u8; 32]>,
}
impl UnlockedOrgVault {
pub fn root(&self) -> &Path { &self.root }
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key }
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
pub fn item_path(&self, id: &relicario_core::ItemId) -> PathBuf {
self.root.join("items").join(format!("{}.enc", id.as_str()))
}
pub fn member_key_path(&self, id: &MemberId) -> PathBuf {
self.root.join("keys").join(format!("{}.enc", id.as_str()))
}
pub fn members_path(&self) -> PathBuf { self.root.join("members.json") }
pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") }
pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") }
pub fn load_meta(&self) -> Result<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:
```rust
mod org_session;
```
- [ ] **Step 4: Run tests**
```bash
cargo test -p relicario-cli org_session 2>&1 | tail -20
```
Expected: all org_session tests pass.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/org_session.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): UnlockedOrgVault session type"
```
> **Note:** `current_device_pubkey()` and `current_device_seed()` are referenced above. These need to be added to `crates/relicario-cli/src/device.rs` in Task 5. If `device.rs` already has a way to get the current device pubkey, use that. Otherwise, implement them in Task 5.
---
## [Dev-B] Task 5: Device seed/pubkey helpers + org commands module stub
**Files:**
- Modify: `crates/relicario-cli/src/device.rs` (or wherever device helpers live)
- Create: `crates/relicario-cli/src/commands/org.rs` (stub)
- Modify: `crates/relicario-cli/src/commands/mod.rs`
- [ ] **Step 1: Check existing device module**
```bash
grep -n "pubkey\|seed\|signing_key\|device" crates/relicario-cli/src/device.rs | head -30
```
Look for existing functions that expose the device's ed25519 signing key or public key. If `current_device_pubkey()` already exists in some form, adapt. If not, proceed to Step 2.
- [ ] **Step 2: Add device seed + pubkey helpers**
In `crates/relicario-cli/src/device.rs` (or create it if the file doesn't exist with these helpers), add:
```rust
/// Read the current device's ed25519 seed from the device key file.
/// The key file location is RELICARIO_DEVICE_KEY env var or ~/.config/relicario/device.key.
pub fn current_device_seed() -> anyhow::Result<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`:
```rust
//! `relicario org` subcommands for multi-user org vault management.
use anyhow::Result;
pub fn run_init(_dir: &std::path::Path, _name: &str) -> Result<()> {
todo!("org init")
}
```
Add to `crates/relicario-cli/src/commands/mod.rs`:
```rust
pub mod org;
```
- [ ] **Step 4: Verify compile**
```bash
cargo check -p relicario-cli
```
Expected: clean (todo! is fine at compile time).
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/device.rs crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/commands/mod.rs
git commit -m "feat(cli/org): device seed/pubkey helpers + org commands stub"
```
---
## [Dev-B] Task 6: org init command
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
`org init` creates the org directory structure, generates the org master key, wraps it to the caller's device key, writes all initial files, runs `git init` + first commit.
- [ ] **Step 1: Write failing integration test**
Create `crates/relicario-cli/tests/org_init.rs`:
```rust
use std::fs;
use tempfile::TempDir;
fn run(args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_relicario"))
.args(args)
.output()
.expect("run relicario")
}
#[test]
#[ignore] // requires a device key on disk; run manually
fn org_init_creates_expected_files() {
let dir = TempDir::new().unwrap();
let path = dir.path().to_str().unwrap();
let out = run(&["org", "init", "--dir", path, "--name", "Test Org"]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(dir.path().join("org.json").exists());
assert!(dir.path().join("members.json").exists());
assert!(dir.path().join("collections.json").exists());
assert!(dir.path().join("manifest.enc").exists());
assert!(dir.path().join(".git").exists());
}
```
```bash
cargo test -p relicario-cli --test org_init 2>&1 | tail -5
```
Expected: test compiles and is skipped (ignored).
- [ ] **Step 2: Implement org init**
Replace `run_init` stub in `commands/org.rs`:
```rust
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use relicario_core::{
generate_org_key, wrap_org_key,
CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember,
encrypt_org_manifest,
};
use crate::org_session::atomic_write;
pub fn run_init(dir: &Path, name: &str) -> Result<()> {
// Create directory structure
fs::create_dir_all(dir.join("items")).context("create items/")?;
fs::create_dir_all(dir.join("keys")).context("create keys/")?;
// Get caller's device info
let device_pubkey = crate::device::current_device_pubkey()
.context("read device key — run `relicario device add` first")?;
// Generate org master key
let org_key = generate_org_key();
// Wrap org key to caller's device key
let wrapped = wrap_org_key(&org_key, &device_pubkey)
.context("wrap org key to device key")?;
// Create initial members.json with caller as owner
let caller_id = MemberId::new();
let now = relicario_core::now_unix();
let member = OrgMember {
member_id: caller_id.clone(),
display_name: whoami(),
role: OrgRole::Owner,
ed25519_pubkey: device_pubkey,
collections: vec![],
added_at: now,
added_by: caller_id.clone(),
};
let mut members = OrgMembers::new();
members.members.push(member);
// Write wrapped key
let key_path = dir.join("keys").join(format!("{}.enc", caller_id.as_str()));
fs::write(&key_path, &wrapped).context("write caller key blob")?;
// Write org.json
let meta = OrgMeta::new(name.to_string());
let meta_json = serde_json::to_string_pretty(&meta)?;
atomic_write(&dir.join("org.json"), meta_json.as_bytes())?;
// Write members.json
let members_json = serde_json::to_string_pretty(&members)?;
atomic_write(&dir.join("members.json"), members_json.as_bytes())?;
// Write collections.json (empty)
let collections = OrgCollections::new();
let coll_json = serde_json::to_string_pretty(&collections)?;
atomic_write(&dir.join("collections.json"), coll_json.as_bytes())?;
// Write empty manifest.enc
let manifest = OrgManifest::new();
let manifest_bytes = encrypt_org_manifest(&manifest, &org_key)?;
atomic_write(&dir.join("manifest.enc"), &manifest_bytes)?;
// git init + initial commit
crate::helpers::git_run(dir, &["init"], "git init")?;
crate::helpers::git_run(dir, &["add", "."], "git add")?;
let commit_msg = format!(
"init: org vault \"{name}\"\n\nRelicario-Actor: {} <{}>\nRelicario-Action: org-init",
members.members[0].display_name,
caller_id.as_str()
);
crate::helpers::git_run(dir, &["commit", "-m", &commit_msg], "git commit")?;
println!("Org vault initialized at {}", dir.display());
println!("Your member ID: {}", caller_id.as_str());
Ok(())
}
fn whoami() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".into())
}
```
- [ ] **Step 3: Build and smoke-test manually**
```bash
cargo build -p relicario-cli 2>&1 | tail -10
```
Expected: clean build.
- [ ] **Step 4: Commit**
```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/tests/org_init.rs
git commit -m "feat(cli/org): org init command"
```
---
## [Dev-B] Task 7: org add-member, remove-member, set-role
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
All three commands share the same open-vault → edit members.json → commit pattern.
- [ ] **Step 1: Write failing unit tests**
Add to `commands/org.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};
fn alice() -> OrgMember {
OrgMember {
member_id: MemberId::new(),
display_name: "Alice".into(),
role: OrgRole::Member,
ed25519_pubkey: "ssh-ed25519 AAAA fake".into(),
collections: vec![],
added_at: 0,
added_by: MemberId::new(),
}
}
#[test]
fn set_role_changes_role() {
let mut members = OrgMembers::new();
let a = alice();
let id = a.member_id.clone();
members.members.push(a);
if let Some(m) = members.find_by_id_mut(&id) {
m.role = OrgRole::Admin;
}
assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin);
}
}
```
```bash
cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5
```
Expected: PASS (pure logic test, no I/O).
- [ ] **Step 2: Implement add-member**
Add to `commands/org.rs`:
```rust
pub fn run_add_member(
dir: &Path,
pubkey: &str,
name: &str,
role: OrgRole,
) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can add members");
}
let mut members = vault.load_members()?;
// Check pubkey not already present
if members.members.iter().any(|m| m.ed25519_pubkey.trim() == pubkey.trim()) {
anyhow::bail!("this public key is already registered in the org");
}
let new_id = MemberId::new();
let now = relicario_core::now_unix();
let wrapped = wrap_org_key(vault.key(), pubkey)
.context("wrap org key to new member's key")?;
fs::write(vault.member_key_path(&new_id), &wrapped)
.context("write member key blob")?;
members.members.push(OrgMember {
member_id: new_id.clone(),
display_name: name.to_string(),
role,
ed25519_pubkey: pubkey.trim().to_string(),
collections: vec![],
added_at: now,
added_by: caller.member_id.clone(),
});
vault.save_members(&members)?;
let commit_msg = format!(
"org: add member \"{name}\"\n\nRelicario-Actor: {} <{}>\nRelicario-Action: member-add\nRelicario-Member: {}",
caller.display_name, caller.member_id.as_str(), new_id.as_str()
);
crate::org_session::org_git_run(
&vault.root,
&["add", "members.json", &format!("keys/{}.enc", new_id.as_str())],
"git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Added {} ({})", name, new_id.as_str());
Ok(())
}
```
- [ ] **Step 3: Implement remove-member**
```rust
pub fn run_remove_member(dir: &Path, member_id_prefix: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can remove members");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
let target = members.find_by_id(&target_id).unwrap();
if target.role == OrgRole::Owner && !caller.role.can_manage_owners() {
anyhow::bail!("only owners can remove other owners");
}
let target_name = target.display_name.clone();
// Delete key blob
let key_path = vault.member_key_path(&target_id);
if key_path.exists() { fs::remove_file(&key_path).context("delete key blob")?; }
members.members.retain(|m| m.member_id != target_id);
vault.save_members(&members)?;
let commit_msg = format!(
"org: remove member \"{target_name}\"\n\nRelicario-Actor: {} <{}>\nRelicario-Action: member-remove\nRelicario-Member: {}",
caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(
&vault.root,
&["add", "members.json", &format!("keys/{}.enc", target_id.as_str())],
"git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
eprintln!("⚠ Run `relicario org rotate-key --dir {}` to complete revocation.", vault.root.display());
println!("Removed {}", target_name);
Ok(())
}
```
- [ ] **Step 4: Implement set-role**
```rust
pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
if matches!(role, OrgRole::Admin | OrgRole::Owner) && !caller.role.can_manage_owners() {
anyhow::bail!("only owners can promote to admin or owner");
}
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can change roles");
}
let target = members.find_by_id_mut(&target_id)
.ok_or_else(|| anyhow::anyhow!("member not found"))?;
let old_role = target.role;
target.role = role;
vault.save_members(&members)?;
let commit_msg = format!(
"org: set role {}{:?}\n\nRelicario-Actor: {} <{}>\nRelicario-Action: member-role-change\nRelicario-Member: {}",
target_id.as_str(), role,
caller.display_name, caller.member_id.as_str(),
target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Changed role {:?}{:?}", old_role, role);
Ok(())
}
/// Resolve a member_id prefix (or full ID) to a MemberId.
fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<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**
```bash
cargo build -p relicario-cli 2>&1 | tail -10
```
Expected: clean build.
- [ ] **Step 6: Commit**
```bash
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): add-member, remove-member, set-role commands"
```
---
## [Dev-B] Task 8: org create-collection, grant, revoke
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- [ ] **Step 1: Write failing test**
Add to the `tests` block in `commands/org.rs`:
```rust
#[test]
fn grant_adds_slug_to_member_collections() {
let mut members = OrgMembers::new();
let a = alice();
let id = a.member_id.clone();
members.members.push(a);
let m = members.find_by_id_mut(&id).unwrap();
if !m.collections.contains(&"prod".to_string()) {
m.collections.push("prod".to_string());
}
assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
}
#[test]
fn revoke_removes_slug_from_member_collections() {
let mut members = OrgMembers::new();
let mut a = alice();
a.collections = vec!["prod".into(), "dev".into()];
let id = a.member_id.clone();
members.members.push(a);
let m = members.find_by_id_mut(&id).unwrap();
m.collections.retain(|s| s != "prod");
assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
}
```
```bash
cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5
```
Expected: all pass.
- [ ] **Step 2: Implement create-collection**
```rust
pub fn run_create_collection(dir: &Path, slug: &str, display_name: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can create collections");
}
let mut collections = vault.load_collections()?;
if collections.contains_slug(slug) {
anyhow::bail!("collection `{slug}` already exists");
}
if slug.is_empty() || slug.contains('/') || slug.contains('.') {
anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string");
}
collections.collections.push(CollectionDef {
slug: slug.to_string(),
display_name: display_name.to_string(),
created_by: caller.member_id.clone(),
created_at: relicario_core::now_unix(),
});
vault.save_collections(&collections)?;
let commit_msg = format!(
"org: create collection \"{slug}\"\n\nRelicario-Actor: {} <{}>\nRelicario-Action: collection-create\nRelicario-Collection: {slug}",
caller.display_name, caller.member_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Created collection `{slug}`");
Ok(())
}
```
- [ ] **Step 3: Implement grant**
```rust
pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can grant collection access");
}
let collections = vault.load_collections()?;
if !collections.contains_slug(slug) {
anyhow::bail!("collection `{slug}` does not exist — create it first");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
let target = members.find_by_id_mut(&target_id).unwrap();
if target.collections.contains(&slug.to_string()) {
anyhow::bail!("member already has access to `{slug}`");
}
target.collections.push(slug.to_string());
vault.save_members(&members)?;
let commit_msg = format!(
"org: grant {slug} to {}\n\nRelicario-Actor: {} <{}>\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\nRelicario-Member: {}",
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Granted `{slug}` to {}", target_id.as_str());
Ok(())
}
```
- [ ] **Step 4: Implement revoke**
```rust
pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can revoke collection access");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
let target = members.find_by_id_mut(&target_id).unwrap();
if !target.collections.contains(&slug.to_string()) {
anyhow::bail!("member does not have access to `{slug}`");
}
target.collections.retain(|s| s != slug);
vault.save_members(&members)?;
let commit_msg = format!(
"org: revoke {slug} from {}\n\nRelicario-Actor: {} <{}>\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\nRelicario-Member: {}",
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Revoked `{slug}` from {}", target_id.as_str());
Ok(())
}
```
- [ ] **Step 5: Compile + commit**
```bash
cargo build -p relicario-cli 2>&1 | tail -5
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): create-collection, grant, revoke commands"
```
---
## [Dev-B] Task 9: org rotate-key
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
`rotate-key` generates a new org master key, re-wraps it for all current members, re-encrypts the manifest (item blobs are NOT re-encrypted), and commits.
- [ ] **Step 1: Write failing test**
Add to tests block:
```rust
#[test]
fn new_key_differs_from_old_key() {
let k1 = relicario_core::generate_org_key();
let k2 = relicario_core::generate_org_key();
assert_ne!(*k1, *k2);
}
```
```bash
cargo test -p relicario-cli commands::org::tests::new_key_differs 2>&1 | tail -5
```
Expected: PASS.
- [ ] **Step 2: Implement rotate-key**
```rust
pub fn run_rotate_key(dir: &Path) -> Result<()> {
// Pull latest state first to detect concurrent rotations
let pull_result = crate::helpers::git_run(dir, &["pull", "--rebase"], "git pull --rebase");
if let Err(e) = pull_result {
// Non-fatal if no remote configured (local-only orgs)
eprintln!("Note: git pull --rebase failed ({}). Proceeding with local state.", e);
}
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_owners() {
anyhow::bail!("only owners can rotate the org master key");
}
let members = vault.load_members()?;
let new_org_key = relicario_core::generate_org_key();
// Re-wrap for all current members
let mut staged_paths: Vec<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**
```bash
cargo build -p relicario-cli 2>&1 | tail -5
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): rotate-key command"
```
---
## [Dev-B] Task 10: org status + org audit
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- [ ] **Step 1: Implement status**
```rust
pub fn run_status(dir: &Path) -> Result<()> {
let root = crate::org_session::org_dir(Some(dir))?;
let meta: relicario_core::OrgMeta = {
let s = fs::read_to_string(root.join("org.json")).context("read org.json")?;
serde_json::from_str(&s)?
};
let members: OrgMembers = {
let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
serde_json::from_str(&s)?
};
let collections: OrgCollections = {
let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?;
serde_json::from_str(&s)?
};
println!("Org: {} ({})", meta.display_name, meta.org_id.as_str());
println!();
println!("Members ({}):", members.members.len());
for m in &members.members {
let colls = if m.collections.is_empty() {
"(no collections)".to_string()
} else {
m.collections.join(", ")
};
println!(" {:?} {} {} [{}]", m.role, m.member_id.as_str(), m.display_name, colls);
}
println!();
println!("Collections ({}):", collections.collections.len());
for c in &collections.collections {
println!(" {}{}", c.slug, c.display_name);
}
Ok(())
}
```
- [ ] **Step 2: Write failing test for audit trailer parsing**
Add to tests block:
```rust
#[test]
fn parse_trailers_extracts_relicario_fields() {
let raw = "Relicario-Actor: alice <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"));
}
```
```bash
cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5
```
Expected: FAIL — `parse_trailer_block` not defined.
- [ ] **Step 3: Implement audit**
```rust
#[derive(Debug, serde::Serialize)]
pub struct AuditEvent {
pub commit: String,
pub timestamp: String,
pub actor_name: Option<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**
```bash
cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5
```
Expected: PASS.
- [ ] **Step 5: Build + commit**
```bash
cargo build -p relicario-cli 2>&1 | tail -5
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): status and audit commands"
```
---
## [Dev-B] Task 11: Wire Commands::Org into main.rs
**Files:**
- Modify: `crates/relicario-cli/src/main.rs`
- [ ] **Step 1: Read the current Commands enum top**
```bash
grep -n "Subcommand\|Commands\|enum\|Org" crates/relicario-cli/src/main.rs | head -40
```
Look for where the `Commands` enum and the `match cli.command` dispatch live.
- [ ] **Step 2: Add Org subcommand to the Commands enum**
In the `Commands` enum, add (after the last existing variant):
```rust
/// Manage a multi-user org vault.
Org {
/// Path to the org vault directory (overrides RELICARIO_ORG_DIR).
#[arg(long, global = true)]
dir: Option<PathBuf>,
#[command(subcommand)]
subcommand: OrgCommands,
},
```
Add the `OrgCommands` enum (top-level, after the `Commands` enum):
```rust
#[derive(Subcommand)]
enum OrgCommands {
/// Create a new org vault.
Init {
#[arg(long)]
name: String,
},
/// Add a member to the org.
AddMember {
/// OpenSSH ed25519 public key of the new member.
#[arg(long)]
key: String,
/// Display name.
#[arg(long)]
name: String,
/// Role: owner, admin, or member.
#[arg(long, default_value = "member")]
role: String,
},
/// Remove a member from the org.
RemoveMember {
/// Member ID prefix.
member_id: String,
},
/// Change a member's role.
SetRole {
member_id: String,
role: String,
},
/// Create a collection.
CreateCollection {
slug: String,
#[arg(long)]
name: String,
},
/// Grant a member access to a collection.
Grant {
member_id: String,
collection: String,
},
/// Revoke a member's access to a collection.
Revoke {
member_id: String,
collection: String,
},
/// Rotate the org master key (run after removing a member).
RotateKey,
/// Show org members and collections.
Status,
/// Query the org audit log.
Audit {
#[arg(long)]
since: Option<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:
```rust
Commands::Org { dir, subcommand } => {
let dir_path = dir.as_deref();
match subcommand {
OrgCommands::Init { name } => {
let d = dir_path.ok_or_else(|| anyhow::anyhow!("--dir required for org init"))?;
commands::org::run_init(d, &name)?;
}
OrgCommands::AddMember { key, name, role } => {
let d = crate::org_session::org_dir(dir_path)?;
let role = parse_org_role(&role)?;
commands::org::run_add_member(&d, &key, &name, role)?;
}
OrgCommands::RemoveMember { member_id } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_remove_member(&d, &member_id)?;
}
OrgCommands::SetRole { member_id, role } => {
let d = crate::org_session::org_dir(dir_path)?;
let role = parse_org_role(&role)?;
commands::org::run_set_role(&d, &member_id, role)?;
}
OrgCommands::CreateCollection { slug, name } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_create_collection(&d, &slug, &name)?;
}
OrgCommands::Grant { member_id, collection } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_grant(&d, &member_id, &collection)?;
}
OrgCommands::Revoke { member_id, collection } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_revoke(&d, &member_id, &collection)?;
}
OrgCommands::RotateKey => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_rotate_key(&d)?;
}
OrgCommands::Status => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_status(&d)?;
}
OrgCommands::Audit { since, member, collection, action, json } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_audit(&d, since.as_deref(), member.as_deref(),
collection.as_deref(), action.as_deref(), json)?;
}
}
}
```
Add the `parse_org_role` helper in `main.rs`:
```rust
fn parse_org_role(s: &str) -> anyhow::Result<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**
```bash
cargo build -p relicario-cli 2>&1 | tail -10
./target/debug/relicario org --help
./target/debug/relicario org init --help
```
Expected: clean build, help text for all org subcommands.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/main.rs
git commit -m "feat(cli): wire Commands::Org subcommand into main.rs"
```
---
## [Dev-C] Task 12: Pre-receive hook org extension in relicario-server
**Files:**
- Modify: `crates/relicario-server/src/main.rs`
The server gains a `verify-org-commit` subcommand that validates:
1. Only owners/admins wrote to `members.json`, `collections.json`, `org.json`
2. Schema version did not decrease
3. (Warning) If a `member-remove` commit happened without a following `key-rotate`, emit a warning on the next push
- [ ] **Step 1: Write failing test**
Create `crates/relicario-server/tests/org_hook.rs`:
```rust
#[test]
fn parse_changed_paths_detects_members_json() {
let paths = vec!["members.json", "items/abc123.enc"];
assert!(paths.iter().any(|p| *p == "members.json"));
}
```
```bash
cargo test -p relicario-server 2>&1 | tail -5
```
Expected: PASS (trivial smoke test to verify the test harness works).
- [ ] **Step 2: Add VerifyOrgCommit subcommand**
In `crates/relicario-server/src/main.rs`, add to the `Commands` enum:
```rust
/// Verify that a commit to an org vault respects role-based path authorization.
VerifyOrgCommit {
/// The commit SHA to verify.
commit: String,
},
/// Generate an org pre-receive hook script.
GenerateOrgHook,
```
Add dispatch in `main()`:
```rust
Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
Commands::GenerateOrgHook => generate_org_hook(),
```
- [ ] **Step 3: Implement verify_org_commit**
```rust
fn verify_org_commit(commit: &str) -> Result<()> {
// Read members.json from the commit tree
let members_json = match git_show(commit, "members.json") {
Ok(s) => s,
Err(_) => {
eprintln!("OK: org commit {commit} (bootstrap - no members.json)");
return Ok(());
}
};
let members: relicario_core::OrgMembers = serde_json::from_str(&members_json)
.context("parse members.json")?;
members.validate().context("members.json schema invalid")?;
// Get changed paths in this commit vs its parent
let parent_output = Command::new("git")
.args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit])
.output()
.context("git diff-tree")?;
let changed_paths: Vec<String> = 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**
```bash
cargo build -p relicario-server 2>&1 | tail -10
```
Expected: clean build.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-server/src/main.rs crates/relicario-server/tests/org_hook.rs
git commit -m "feat(server): verify-org-commit + generate-org-hook subcommands"
```
---
## Task 13: Full org lifecycle integration test
**Files:**
- Create: `crates/relicario-core/tests/org.rs`
This test exercises the complete core-level org flow without requiring a device key on disk.
- [ ] **Step 1: Write the integration test**
Create `crates/relicario-core/tests/org.rs`:
```rust
use relicario_core::{
generate_org_key, wrap_org_key, unwrap_org_key,
encrypt_org_manifest, decrypt_org_manifest,
OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgRole,
MemberId, ItemId,
};
use relicario_core::item_types::ItemType;
use rand::rngs::OsRng;
use rand::RngCore;
use zeroize::Zeroizing;
fn make_member_keypair() -> (Zeroizing<[u8; 32]>, String) {
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
let pubkey_openssh = ssh_key::PrivateKey::from(signing_key)
.public_key()
.to_openssh()
.expect("openssh");
(Zeroizing::new(seed), pubkey_openssh)
}
#[test]
fn org_key_wrap_unwrap_round_trip() {
let (seed, pubkey) = make_member_keypair();
let org_key = generate_org_key();
let wrapped = wrap_org_key(&org_key, &pubkey).expect("wrap");
let unwrapped = unwrap_org_key(&wrapped, &seed).expect("unwrap");
assert_eq!(*org_key, *unwrapped);
}
#[test]
fn revoked_member_cannot_decrypt_after_rotation() {
// Alice and Bob both get access
let (alice_seed, alice_pubkey) = make_member_keypair();
let (_bob_seed, bob_pubkey) = make_member_keypair();
let org_key = generate_org_key();
let _alice_wrapped = wrap_org_key(&org_key, &alice_pubkey).expect("wrap alice");
let _bob_wrapped = wrap_org_key(&org_key, &bob_pubkey).expect("wrap bob");
// Rotate: new key, only Bob gets re-wrapped
let new_org_key = generate_org_key();
let new_bob_wrapped = wrap_org_key(&new_org_key, &bob_pubkey).expect("wrap bob new");
// Alice tries to use old org_key — she can still decrypt old items,
// but new_bob_wrapped was encrypted with new_org_key, not org_key.
// Verify: unwrapping new_bob_wrapped with Alice's seed fails.
let result = unwrap_org_key(&new_bob_wrapped, &alice_seed);
assert!(result.is_err(), "Alice should not be able to unwrap Bob's new key blob");
}
#[test]
fn org_manifest_filter_restricts_to_granted_collections() {
let mut manifest = OrgManifest::new();
for (title, collection) in &[("A", "prod"), ("B", "dev"), ("C", "prod")] {
manifest.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: ItemType::SecureNote,
title: title.to_string(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: collection.to_string(),
});
}
let member = OrgMember {
member_id: MemberId::new(),
display_name: "Alice".into(),
role: OrgRole::Member,
ed25519_pubkey: String::new(),
collections: vec!["prod".into()],
added_at: 0,
added_by: MemberId::new(),
};
let filtered = manifest.filter_for_member(&member);
assert_eq!(filtered.entries.len(), 2);
assert!(filtered.entries.iter().all(|e| e.collection == "prod"));
}
#[test]
fn org_manifest_encrypt_decrypt_round_trip() {
let key = generate_org_key();
let mut manifest = OrgManifest::new();
manifest.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: ItemType::Login,
title: "GitHub".into(),
tags: vec!["work".into()],
modified: 1748000000,
trashed_at: None,
collection: "eng-tools".into(),
});
let encrypted = encrypt_org_manifest(&manifest, &key).expect("encrypt");
let decrypted = decrypt_org_manifest(&encrypted, &key).expect("decrypt");
assert_eq!(decrypted.entries.len(), 1);
assert_eq!(decrypted.entries[0].title, "GitHub");
assert_eq!(decrypted.entries[0].collection, "eng-tools");
}
#[test]
fn members_validation_rejects_invalid_id() {
let mut members = OrgMembers::new();
members.members.push(OrgMember {
member_id: MemberId("not-hex-lol!!".to_string()),
display_name: "Bad".into(),
role: OrgRole::Member,
ed25519_pubkey: String::new(),
collections: vec![],
added_at: 0,
added_by: MemberId::new(),
});
assert!(members.validate().is_err());
}
```
- [ ] **Step 2: Add ed25519-dalek and ssh-key as dev-dependencies in relicario-core**
In `crates/relicario-core/Cargo.toml`, add to `[dev-dependencies]`:
```toml
ed25519-dalek = { version = "2", features = ["rand_core"] }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
```
(These are already in `[dependencies]` — just make sure they're also available in tests. If they're already in `[dependencies]`, they're already available; skip this step.)
- [ ] **Step 3: Run all org integration tests**
```bash
cargo test -p relicario-core --test org 2>&1 | tail -20
```
Expected: all 5 tests pass.
- [ ] **Step 4: Run the full test suite**
```bash
cargo test 2>&1 | tail -20
```
Expected: all tests pass across all crates.
- [ ] **Step 5: Final commit**
```bash
git add crates/relicario-core/tests/org.rs crates/relicario-core/Cargo.toml
git commit -m "test(core/org): full org lifecycle integration tests"
```
---
## Self-Review
**Spec coverage check:**
| Spec section | Covered by task(s) |
|---|---|
| Org master key (256-bit random, wrapped per-member) | Tasks 2, 3, 13 |
| `org.json`, `members.json`, `collections.json` data model | Tasks 2, 6 |
| `keys/<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.