2469 lines
79 KiB
Markdown
2469 lines
79 KiB
Markdown
# 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:
|
||
|
||
```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, 6–12. `wrap_org_key` / `unwrap_org_key` / `generate_org_key` defined in Task 2, used in Tasks 6, 7, 9, 13.
|