Compare commits
8 Commits
feature/v0
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76d7167d6 | ||
|
|
04ad98973a | ||
|
|
290bc4e2d0 | ||
|
|
82feb49ab4 | ||
|
|
07862b8d44 | ||
|
|
b09e0ce036 | ||
|
|
db4e05a193 | ||
|
|
d32af594e4 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2220,7 +2220,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-server"
|
name = "relicario-server"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ use std::path::Path;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use relicario_core::{
|
use relicario_core::{
|
||||||
generate_org_key, wrap_org_key,
|
generate_org_key, wrap_org_key,
|
||||||
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
|
CollectionDef, Item, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
|
||||||
OrgRole, OrgMember,
|
OrgRole, OrgMember,
|
||||||
encrypt_org_manifest,
|
encrypt_org_manifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::org_session::atomic_write;
|
use crate::org_session::atomic_write;
|
||||||
|
use crate::commands::item_build as ib;
|
||||||
|
|
||||||
pub fn run_init(dir: &Path, name: &str) -> Result<()> {
|
pub fn run_init(dir: &Path, name: &str) -> Result<()> {
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
@@ -745,17 +746,20 @@ pub fn run_audit(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Item kinds `org add` supports without interactive prompts.
|
/// Item kinds `org add` supports. Secrets resolve via `--*-stdin` flags or an
|
||||||
|
/// interactive prompt inside the shared `item_build` builders.
|
||||||
pub enum OrgAddKind {
|
pub enum OrgAddKind {
|
||||||
Login {
|
Login {
|
||||||
title: String,
|
title: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
password_stdin: bool,
|
||||||
},
|
},
|
||||||
SecureNote {
|
SecureNote {
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
body: Option<String>,
|
||||||
|
body_stdin: bool,
|
||||||
},
|
},
|
||||||
Identity {
|
Identity {
|
||||||
title: String,
|
title: String,
|
||||||
@@ -763,43 +767,56 @@ pub enum OrgAddKind {
|
|||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
phone: Option<String>,
|
phone: Option<String>,
|
||||||
},
|
},
|
||||||
|
Card {
|
||||||
|
title: String,
|
||||||
|
holder: Option<String>,
|
||||||
|
expiry: Option<String>,
|
||||||
|
kind: String,
|
||||||
|
number_stdin: bool,
|
||||||
|
cvv_stdin: bool,
|
||||||
|
pin_stdin: bool,
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
title: String,
|
||||||
|
label: Option<String>,
|
||||||
|
algorithm: Option<String>,
|
||||||
|
public_key: Option<String>,
|
||||||
|
material_stdin: bool,
|
||||||
|
},
|
||||||
|
Totp {
|
||||||
|
title: String,
|
||||||
|
issuer: Option<String>,
|
||||||
|
label: Option<String>,
|
||||||
|
secret: Option<String>,
|
||||||
|
secret_stdin: bool,
|
||||||
|
period: u32,
|
||||||
|
digits: u8,
|
||||||
|
algorithm: String,
|
||||||
|
},
|
||||||
|
// Document is added later by Dev-C.
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
|
fn build_org_item(kind: OrgAddKind) -> Result<Item> {
|
||||||
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
|
match kind {
|
||||||
use zeroize::Zeroizing;
|
OrgAddKind::Login { title, username, url, password, password_stdin } => {
|
||||||
|
ib::build_login(title, username, url, password, password_stdin, false, None)
|
||||||
let mut item = match kind {
|
|
||||||
OrgAddKind::Login { title, username, url, password } => {
|
|
||||||
let parsed_url = match url {
|
|
||||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let password = password.map(Zeroizing::new);
|
|
||||||
Item::new(title, ItemCore::Login(LoginCore {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
url: parsed_url,
|
|
||||||
totp: None,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
OrgAddKind::SecureNote { title, body } => {
|
OrgAddKind::SecureNote { title, body, body_stdin } => {
|
||||||
Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
ib::build_secure_note(title, body, body_stdin)
|
||||||
body: Zeroizing::new(body),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
OrgAddKind::Identity { title, full_name, email, phone } => {
|
OrgAddKind::Identity { title, full_name, email, phone } => {
|
||||||
Item::new(title, ItemCore::Identity(IdentityCore {
|
ib::build_identity(title, full_name, email, phone, None)
|
||||||
full_name,
|
}
|
||||||
address: None,
|
OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } => {
|
||||||
phone,
|
ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)
|
||||||
email,
|
}
|
||||||
date_of_birth: None,
|
OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } => {
|
||||||
}))
|
ib::build_key(title, label, algorithm, public_key, material_stdin)
|
||||||
|
}
|
||||||
|
OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => {
|
||||||
|
ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
item.tags = tags;
|
|
||||||
Ok(item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
|
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
|
||||||
@@ -816,7 +833,8 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
|
|||||||
// …and the caller must hold a grant for it.
|
// …and the caller must hold a grant for it.
|
||||||
UnlockedOrgVault::ensure_grant(&caller, collection)?;
|
UnlockedOrgVault::ensure_grant(&caller, collection)?;
|
||||||
|
|
||||||
let item = build_org_item(kind, tags)?;
|
let mut item = build_org_item(kind)?;
|
||||||
|
item.tags = tags;
|
||||||
let item_rel = vault.save_item(collection, &item)?;
|
let item_rel = vault.save_item(collection, &item)?;
|
||||||
|
|
||||||
// Upsert the manifest entry (collection slug stored plaintext inside the
|
// Upsert the manifest entry (collection slug stored plaintext inside the
|
||||||
@@ -973,21 +991,9 @@ fn resolve_org_query<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_edit(
|
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) -> Result<()> {
|
||||||
dir: &Path,
|
|
||||||
query: &str,
|
|
||||||
title: Option<String>,
|
|
||||||
username: Option<String>,
|
|
||||||
url: Option<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
body: Option<String>,
|
|
||||||
email: Option<String>,
|
|
||||||
phone: Option<String>,
|
|
||||||
full_name: Option<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
use relicario_core::time::now_unix;
|
use relicario_core::time::now_unix;
|
||||||
use relicario_core::ItemCore;
|
use relicario_core::ItemCore;
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||||
let caller = vault.current_member()?;
|
let caller = vault.current_member()?;
|
||||||
@@ -999,31 +1005,28 @@ pub fn run_edit(
|
|||||||
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
|
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
|
||||||
|
|
||||||
let mut item = vault.load_item(&collection, &id)?;
|
let mut item = vault.load_item(&collection, &id)?;
|
||||||
|
eprintln!(
|
||||||
|
"Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
||||||
|
item.title,
|
||||||
|
item.id.as_str()
|
||||||
|
);
|
||||||
|
if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? {
|
||||||
|
item.title = v;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(t) = title { item.title = t; }
|
let history = &mut item.field_history;
|
||||||
|
|
||||||
match &mut item.core {
|
match &mut item.core {
|
||||||
ItemCore::Login(l) => {
|
ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
|
||||||
if let Some(u) = username { l.username = Some(u); }
|
ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
|
||||||
if let Some(u) = url {
|
ItemCore::Identity(i) => ib::edit_identity(i)?,
|
||||||
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
|
ItemCore::Card(c) => ib::edit_card(c, history)?,
|
||||||
}
|
ItemCore::Key(k) => ib::edit_key(k, history)?,
|
||||||
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
|
ItemCore::Document(_) => ib::edit_document_message(),
|
||||||
}
|
ItemCore::Totp(t) => ib::edit_totp(t, history)?,
|
||||||
ItemCore::SecureNote(n) => {
|
|
||||||
if let Some(b) = body { n.body = Zeroizing::new(b); }
|
|
||||||
}
|
|
||||||
ItemCore::Identity(i) => {
|
|
||||||
if let Some(v) = full_name { i.full_name = Some(v); }
|
|
||||||
if let Some(v) = email { i.email = Some(v); }
|
|
||||||
if let Some(v) = phone { i.phone = Some(v); }
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item.modified = now_unix();
|
item.modified = now_unix();
|
||||||
let item_rel = vault.save_item(&collection, &item)?;
|
let item_rel = vault.save_item(&collection, &item)?;
|
||||||
|
|
||||||
let mut manifest = vault.load_manifest()?;
|
let mut manifest = vault.load_manifest()?;
|
||||||
upsert_org_entry(&mut manifest, &item, &collection);
|
upsert_org_entry(&mut manifest, &item, &collection);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
@@ -1035,12 +1038,14 @@ pub fn run_edit(
|
|||||||
);
|
);
|
||||||
let commit_msg = format!(
|
let commit_msg = format!(
|
||||||
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
|
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||||
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
|
caller.display_name,
|
||||||
|
caller.member_id.as_str(),
|
||||||
|
collection,
|
||||||
|
item.id.as_str()
|
||||||
);
|
);
|
||||||
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
|
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
|
||||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
|
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
|
||||||
|
println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
|
||||||
println!("Updated {}", item.id.as_str());
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -535,18 +535,12 @@ pub(crate) enum OrgCommands {
|
|||||||
List {
|
List {
|
||||||
#[arg(long)] trashed: bool,
|
#[arg(long)] trashed: bool,
|
||||||
},
|
},
|
||||||
/// Edit an org item's fields (flag-driven; blank flags keep current values).
|
/// Edit an org item interactively (per-type prompts; blank keeps current).
|
||||||
Edit {
|
Edit {
|
||||||
/// Item id or case-insensitive title substring.
|
/// Item id or case-insensitive title substring.
|
||||||
query: String,
|
query: String,
|
||||||
#[arg(long)] title: Option<String>,
|
/// Replace the login TOTP secret from a QR image.
|
||||||
#[arg(long)] username: Option<String>,
|
#[arg(long)] totp_qr: Option<std::path::PathBuf>,
|
||||||
#[arg(long)] url: Option<String>,
|
|
||||||
#[arg(long)] password: Option<String>,
|
|
||||||
#[arg(long)] body: Option<String>,
|
|
||||||
#[arg(long)] email: Option<String>,
|
|
||||||
#[arg(long)] phone: Option<String>,
|
|
||||||
#[arg(long)] full_name: Option<String>,
|
|
||||||
},
|
},
|
||||||
/// Soft-delete an org item (reversible via `org restore`).
|
/// Soft-delete an org item (reversible via `org restore`).
|
||||||
Rm { query: String },
|
Rm { query: String },
|
||||||
@@ -566,13 +560,15 @@ pub(crate) enum OrgAddKind {
|
|||||||
#[arg(long)] url: Option<String>,
|
#[arg(long)] url: Option<String>,
|
||||||
#[arg(long)] password: Option<String>,
|
#[arg(long)] password: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] password_stdin: bool,
|
||||||
},
|
},
|
||||||
/// A secure note.
|
/// A secure note.
|
||||||
SecureNote {
|
SecureNote {
|
||||||
#[arg(long)] collection: String,
|
#[arg(long)] collection: String,
|
||||||
#[arg(long)] title: String,
|
#[arg(long)] title: String,
|
||||||
#[arg(long)] body: String,
|
#[arg(long)] body: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] body_stdin: bool,
|
||||||
},
|
},
|
||||||
/// An identity record.
|
/// An identity record.
|
||||||
Identity {
|
Identity {
|
||||||
@@ -583,6 +579,41 @@ pub(crate) enum OrgAddKind {
|
|||||||
#[arg(long)] phone: Option<String>,
|
#[arg(long)] phone: Option<String>,
|
||||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
},
|
},
|
||||||
|
/// A payment card (number / cvv / pin entered via --*-stdin or prompt).
|
||||||
|
Card {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] holder: Option<String>,
|
||||||
|
#[arg(long)] expiry: Option<String>,
|
||||||
|
#[arg(long, default_value = "credit")] kind: String,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] number_stdin: bool,
|
||||||
|
#[arg(long)] cvv_stdin: bool,
|
||||||
|
#[arg(long)] pin_stdin: bool,
|
||||||
|
},
|
||||||
|
/// A key / credential blob (material entered via --material-stdin or prompt).
|
||||||
|
Key {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] label: Option<String>,
|
||||||
|
#[arg(long)] algorithm: Option<String>,
|
||||||
|
#[arg(long)] public_key: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] material_stdin: bool,
|
||||||
|
},
|
||||||
|
/// A TOTP authenticator (base32 secret via --secret or --secret-stdin).
|
||||||
|
Totp {
|
||||||
|
#[arg(long)] collection: String,
|
||||||
|
#[arg(long)] title: String,
|
||||||
|
#[arg(long)] issuer: Option<String>,
|
||||||
|
#[arg(long)] label: Option<String>,
|
||||||
|
#[arg(long)] secret: Option<String>,
|
||||||
|
#[arg(long, default_value_t = 30)] period: u32,
|
||||||
|
#[arg(long, default_value_t = 6)] digits: u8,
|
||||||
|
#[arg(long, default_value = "sha1")] algorithm: String,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] secret_stdin: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -676,14 +707,14 @@ fn main() -> Result<()> {
|
|||||||
OrgCommands::Add { kind } => {
|
OrgCommands::Add { kind } => {
|
||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
let (collection, add_kind, tags) = match kind {
|
let (collection, add_kind, tags) = match kind {
|
||||||
OrgAddKind::Login { collection, title, username, url, password, tags } => (
|
OrgAddKind::Login { collection, title, username, url, password, tags, password_stdin } => (
|
||||||
collection,
|
collection,
|
||||||
commands::org::OrgAddKind::Login { title, username, url, password },
|
commands::org::OrgAddKind::Login { title, username, url, password, password_stdin },
|
||||||
tags,
|
tags,
|
||||||
),
|
),
|
||||||
OrgAddKind::SecureNote { collection, title, body, tags } => (
|
OrgAddKind::SecureNote { collection, title, body, tags, body_stdin } => (
|
||||||
collection,
|
collection,
|
||||||
commands::org::OrgAddKind::SecureNote { title, body },
|
commands::org::OrgAddKind::SecureNote { title, body, body_stdin },
|
||||||
tags,
|
tags,
|
||||||
),
|
),
|
||||||
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
|
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
|
||||||
@@ -691,6 +722,21 @@ fn main() -> Result<()> {
|
|||||||
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
|
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
|
||||||
tags,
|
tags,
|
||||||
),
|
),
|
||||||
|
OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
|
OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
|
OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => (
|
||||||
|
collection,
|
||||||
|
commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm },
|
||||||
|
tags,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
commands::org::run_add(&d, &collection, add_kind, tags)?;
|
commands::org::run_add(&d, &collection, add_kind, tags)?;
|
||||||
}
|
}
|
||||||
@@ -702,9 +748,9 @@ fn main() -> Result<()> {
|
|||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
commands::org::run_list(&d, trashed)?;
|
commands::org::run_list(&d, trashed)?;
|
||||||
}
|
}
|
||||||
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => {
|
OrgCommands::Edit { query, totp_qr } => {
|
||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?;
|
commands::org::run_edit(&d, &query, totp_qr)?;
|
||||||
}
|
}
|
||||||
OrgCommands::Rm { query } => {
|
OrgCommands::Rm { query } => {
|
||||||
let d = crate::org_session::org_dir(dir_path)?;
|
let d = crate::org_session::org_dir(dir_path)?;
|
||||||
|
|||||||
@@ -152,7 +152,9 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (label, args) in [
|
for (label, args) in [
|
||||||
("edit", vec!["org", "edit", "GitHub", "--username", "evil"]),
|
// `org edit` is now interactive (no flat flags); the ungranted member is
|
||||||
|
// rejected at manifest lookup, before any prompt is read.
|
||||||
|
("edit", vec!["org", "edit", "GitHub"]),
|
||||||
("rm", vec!["org", "rm", "GitHub"]),
|
("rm", vec!["org", "rm", "GitHub"]),
|
||||||
("restore", vec!["org", "restore", "GitHub"]),
|
("restore", vec!["org", "restore", "GitHub"]),
|
||||||
("purge", vec!["org", "purge", "GitHub"]),
|
("purge", vec!["org", "purge", "GitHub"]),
|
||||||
@@ -170,13 +172,12 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The item is untouched: the owner can still read the original password and
|
// The item is untouched: the owner can still read the original password and
|
||||||
// the username was NOT changed to the ungranted member's "evil" attempt.
|
// username — the ungranted member's get/edit/rm/restore/purge were all denied.
|
||||||
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
||||||
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
|
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
|
||||||
assert!(owner_get.status.success(), "owner should still read the item");
|
assert!(owner_get.status.success(), "owner should still read the item");
|
||||||
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
|
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
|
||||||
assert!(owner_out.contains("alice"), "edit by ungranted member must not have changed username: {owner_out}");
|
assert!(owner_out.contains("alice"), "ungranted member must not have modified the item: {owner_out}");
|
||||||
assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -67,6 +67,39 @@ impl OrgFixture {
|
|||||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||||
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
|
||||||
|
/// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
|
||||||
|
/// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
|
||||||
|
fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
|
||||||
|
use std::io::Write as _;
|
||||||
|
let mut child = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.env("XDG_CONFIG_HOME", &self.xdg)
|
||||||
|
.env("RELICARIO_ORG_DIR", self.vault.path())
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create collection `slug` and grant the owner access to it — the common
|
||||||
|
/// setup the item-type round-trip tests share.
|
||||||
|
fn create_collection_and_grant(&self, slug: &str) {
|
||||||
|
let owner = self.owner_member_id();
|
||||||
|
assert!(
|
||||||
|
self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
|
||||||
|
"create-collection {slug} failed",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.run(&["org", "grant", &owner, slug]).status.success(),
|
||||||
|
"grant {slug} failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -151,21 +184,17 @@ fn org_add_rejects_unknown_collection() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn org_edit_updates_fields_and_commits_update_trailer() {
|
fn org_edit_updates_fields_and_commits_update_trailer() {
|
||||||
let f = OrgFixture::new();
|
let f = OrgFixture::new();
|
||||||
let owner = f.owner_member_id();
|
f.create_collection_and_grant("prod");
|
||||||
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
|
||||||
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
|
||||||
assert!(f.run(&[
|
assert!(f.run(&[
|
||||||
"org", "add", "login", "--collection", "prod",
|
"org", "add", "login", "--collection", "prod",
|
||||||
"--title", "Mail", "--username", "old", "--password", "pw",
|
"--title", "Mail", "--username", "old", "--password", "pw",
|
||||||
]).status.success());
|
]).status.success());
|
||||||
|
|
||||||
// Edit the username.
|
// org edit is now interactive per-type: keep title, set username=new-user,
|
||||||
let out = f.run(&[
|
// keep URL, decline password change.
|
||||||
"org", "edit", "Mail", "--username", "new-user",
|
let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n");
|
||||||
]);
|
|
||||||
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
|
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
// get --show reflects the new username.
|
|
||||||
let out = f.run(&["org", "get", "Mail", "--show"]);
|
let out = f.run(&["org", "get", "Mail", "--show"]);
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
||||||
@@ -215,3 +244,215 @@ fn org_rm_restore_purge_cycle() {
|
|||||||
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||||
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
|
||||||
|
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
|
||||||
|
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
|
||||||
|
// must mask every secret unless `--show` is passed — asserted below.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_card_via_stdin_then_get_masks_secret() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
|
||||||
|
// build_card reads number, then cvv, then pin — one line each, in that order.
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&[
|
||||||
|
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
|
||||||
|
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
|
||||||
|
],
|
||||||
|
"4111111111111111\n123\n4321\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// get masks the card number by default.
|
||||||
|
let got = f.run(&["org", "get", "Corp Visa"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
|
||||||
|
assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");
|
||||||
|
|
||||||
|
// --show reveals it.
|
||||||
|
let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
|
||||||
|
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||||
|
assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_key_via_stdin_then_get_masks_material() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
|
||||||
|
// build_key reads key material from stdin to EOF (multiline secret).
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&[
|
||||||
|
"org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
|
||||||
|
"--label", "ci", "--algorithm", "ed25519", "--material-stdin",
|
||||||
|
],
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Deploy Key"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("Label: ci"), "label missing: {stdout}");
|
||||||
|
assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_totp_with_secret_flag_round_trips() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
|
||||||
|
// Totp accepts the base32 secret via --secret (no stdin needed).
|
||||||
|
let out = f.run(&[
|
||||||
|
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
|
||||||
|
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "AWS root"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("AWS root"), "title missing: {stdout}");
|
||||||
|
assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_card_interactive_changes_holder() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&[
|
||||||
|
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
|
||||||
|
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
|
||||||
|
],
|
||||||
|
"4111111111111111\n123\n4321\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Interactive edit: keep title, set holder, decline number change.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n");
|
||||||
|
assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Corp Visa"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}");
|
||||||
|
assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}");
|
||||||
|
assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_totp_interactive_changes_issuer() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
assert!(f.run(&[
|
||||||
|
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
|
||||||
|
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
|
||||||
|
]).status.success());
|
||||||
|
|
||||||
|
// Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n");
|
||||||
|
assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "AWS root"]);
|
||||||
|
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- grant enforcement + remaining --*-stdin paths for the new types ---------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
// `secret` exists but is NOT granted to the owner.
|
||||||
|
assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success());
|
||||||
|
|
||||||
|
// ensure_grant runs before any secret prompt in run_add, so these need no
|
||||||
|
// stdin — each new type must be rejected for a collection it lacks a grant for.
|
||||||
|
for args in [
|
||||||
|
vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"],
|
||||||
|
vec!["org", "add", "key", "--collection", "secret", "--title", "X"],
|
||||||
|
vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
|
||||||
|
] {
|
||||||
|
let out = f.run(&args);
|
||||||
|
assert!(!out.status.success(), "ungranted add must fail: {args:?}");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(err.contains("access denied") || err.contains("grant"),
|
||||||
|
"expected grant denial for {args:?}: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// …and rejected for a nonexistent collection.
|
||||||
|
for args in [
|
||||||
|
vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"],
|
||||||
|
vec!["org", "add", "key", "--collection", "ghost", "--title", "X"],
|
||||||
|
vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
|
||||||
|
] {
|
||||||
|
let out = f.run(&args);
|
||||||
|
assert!(!out.status.success(), "unknown-collection add must fail: {args:?}");
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
assert!(err.contains("does not exist") || err.contains("ghost"),
|
||||||
|
"expected unknown-collection error for {args:?}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_secure_note_via_body_stdin_masks_body() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
// build_secure_note(body_stdin=true) reads the body from stdin to EOF.
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"],
|
||||||
|
"line one\nsuper-secret-line\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "Runbook"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||||
|
assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}");
|
||||||
|
assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}");
|
||||||
|
|
||||||
|
let shown = f.run(&["org", "get", "Runbook", "--show"]);
|
||||||
|
assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_add_totp_via_secret_stdin_round_trips() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
// build_totp(secret_stdin=true) reads one base32 line from stdin.
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"],
|
||||||
|
"JBSWY3DPEHPK3PXP\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let got = f.run(&["org", "get", "VPN"]);
|
||||||
|
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: Corp"), "issuer missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn org_edit_key_replaces_material_and_reveals_with_show() {
|
||||||
|
let f = OrgFixture::new();
|
||||||
|
f.create_collection_and_grant("eng");
|
||||||
|
let out = f.run_stdin(
|
||||||
|
&["org", "add", "key", "--collection", "eng", "--title", "Signing Key",
|
||||||
|
"--label", "ci", "--material-stdin"],
|
||||||
|
"OLD-MATERIAL-aaaa\n",
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Interactive edit: keep title, ACCEPT "Replace key material?" -> new material
|
||||||
|
// read from stdin to EOF (edit_key). Exercises the accept branch + history push.
|
||||||
|
let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n");
|
||||||
|
assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let masked = f.run(&["org", "get", "Signing Key"]);
|
||||||
|
let masked = String::from_utf8_lossy(&masked.stdout).to_string();
|
||||||
|
assert!(masked.contains("********"), "material must be masked without --show: {masked}");
|
||||||
|
assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}");
|
||||||
|
|
||||||
|
let shown = f.run(&["org", "get", "Signing Key", "--show"]);
|
||||||
|
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||||
|
assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
|
||||||
|
assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-server"
|
name = "relicario-server"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Pre-receive Git hook for relicario password manager"
|
description = "Pre-receive Git hook for relicario password manager"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
pub enum PathClass {
|
pub enum PathClass {
|
||||||
/// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
|
/// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
|
||||||
Protected,
|
Protected,
|
||||||
/// `items/<slug>/<id>.enc` — writer must hold a grant for `<slug>`.
|
/// `items/<slug>/<id>.enc` and `attachments/<slug>/<item-id>/<att-id>.enc` —
|
||||||
|
/// writer must hold a grant for `<slug>`.
|
||||||
Item { collection: String },
|
Item { collection: String },
|
||||||
/// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
|
/// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
|
||||||
/// per-commit signature check (signer must be a current member).
|
/// per-commit signature check (signer must be a current member).
|
||||||
@@ -42,6 +43,23 @@ pub fn classify_path(path: &str) -> PathClass {
|
|||||||
return PathClass::Item { collection: slug.to_string() };
|
return PathClass::Item { collection: slug.to_string() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(rest) = path.strip_prefix("attachments/") {
|
||||||
|
// Expect exactly: <slug>/<item-id>/<att-id>.enc → three segments.
|
||||||
|
let segments: Vec<&str> = rest.split('/').collect();
|
||||||
|
if segments.len() != 3 {
|
||||||
|
return PathClass::Rejected(
|
||||||
|
"attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string());
|
||||||
|
}
|
||||||
|
let slug = segments[0];
|
||||||
|
if slug.is_empty() {
|
||||||
|
return PathClass::Rejected("empty collection slug in attachments path".to_string());
|
||||||
|
}
|
||||||
|
if slug.contains('.') {
|
||||||
|
return PathClass::Rejected(format!("invalid collection slug: {:?}", slug));
|
||||||
|
}
|
||||||
|
return PathClass::Item { collection: slug.to_string() };
|
||||||
|
}
|
||||||
|
|
||||||
PathClass::Unrestricted
|
PathClass::Unrestricted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,3 +79,43 @@ fn extract_schema_version_errors_on_missing_field() {
|
|||||||
fn extract_schema_version_errors_on_garbage() {
|
fn extract_schema_version_errors_on_garbage() {
|
||||||
assert!(extract_schema_version("not json").is_err());
|
assert!(extract_schema_version("not json").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_path_is_collection_scoped() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("attachments/prod/a1b2c3d4e5f6a1b2/0011223344556677.enc"),
|
||||||
|
PathClass::Item { collection: "prod".to_string() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_wrong_segment_count_is_rejected() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("attachments/prod/onlytwo.enc"),
|
||||||
|
PathClass::Rejected("attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_empty_or_dotted_slug_is_rejected() {
|
||||||
|
assert!(matches!(classify_path("attachments//item/att.enc"), PathClass::Rejected(_)));
|
||||||
|
assert!(matches!(classify_path("attachments/../item/att.enc"), PathClass::Rejected(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachments_prefix_alone_is_rejected_not_unrestricted() {
|
||||||
|
// `attachments/` with no slug/item/att segments must be Rejected, NOT fall
|
||||||
|
// through to Unrestricted — that fall-through was the authz gap this closes.
|
||||||
|
assert!(matches!(classify_path("attachments/"), PathClass::Rejected(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_att_id_segment_may_contain_dots() {
|
||||||
|
// The `.`-free guard applies to the slug (segment[0]) ONLY; the att-id segment
|
||||||
|
// legitimately carries `.enc` and is unharmed by additional dots — proving the
|
||||||
|
// guard is not a blanket "reject any dotted segment".
|
||||||
|
assert_eq!(
|
||||||
|
classify_path("attachments/eng/a1b2c3d4e5f6a1b2/00112233.aux.enc"),
|
||||||
|
PathClass::Item { collection: "eng".to_string() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,10 +111,11 @@ before they land.
|
|||||||
rejected outright.
|
rejected outright.
|
||||||
|
|
||||||
2. **Path-level write authorisation** — each modified path is classified by
|
2. **Path-level write authorisation** — each modified path is classified by
|
||||||
`classify_path` (`crates/relicario-server/src/lib.rs:19`) into
|
`classify_path` (`crates/relicario-server/src/lib.rs:20`) into
|
||||||
`ProtectedJson` (owner/admin write only), `CollectionItem` (the
|
`Protected` (owner/admin write only), `Item { collection }` (the
|
||||||
`items/<slug>/…` prefix; write allowed only if the slug appears in the
|
`items/<slug>/…` or `attachments/<slug>/…` prefix; write allowed only if
|
||||||
signer's `collections` grant array), or `Unrestricted`. The write is
|
the slug appears in the signer's `collections` grant array), or
|
||||||
|
`Unrestricted`. The write is
|
||||||
authorised if and only if the signer's role and grants satisfy the
|
authorised if and only if the signer's role and grants satisfy the
|
||||||
classification. Item blobs are authorised by the leading path segment
|
classification. Item blobs are authorised by the leading path segment
|
||||||
alone — the ciphertext is never decrypted by the hook.
|
alone — the ciphertext is never decrypted by the hook.
|
||||||
@@ -132,6 +133,21 @@ before they land.
|
|||||||
Merge commits are rejected. A genesis commit (no parents) is allowed
|
Merge commits are rejected. A genesis commit (no parents) is allowed
|
||||||
only when it is signed by the sole Owner it introduces.
|
only when it is signed by the sole Owner it introduces.
|
||||||
|
|
||||||
|
#### Attachment write authorisation (v0.1.1 fix)
|
||||||
|
|
||||||
|
Prior to `relicario-server` v0.1.1, `attachments/…` paths fell through to
|
||||||
|
`PathClass::Unrestricted` in `classify_path`
|
||||||
|
(`crates/relicario-server/src/lib.rs:20`). Any member with push access could
|
||||||
|
write attachment blobs to any collection regardless of their grants. As of
|
||||||
|
v0.1.1, `attachments/<slug>/<item-id>/<att-id>.enc` is classified as
|
||||||
|
`PathClass::Item { collection: slug }`, bringing attachment writes under the
|
||||||
|
same grant check already applied to `items/<slug>/<id>.enc` blobs.
|
||||||
|
|
||||||
|
**Deploying this fix requires rebuilding and redeploying the pre-receive hook
|
||||||
|
on the server.** A server still running a hook built before v0.1.1 continues
|
||||||
|
to accept attachment pushes from any member; the `Unrestricted` path is only
|
||||||
|
closed once the updated hook is installed at `<repo>/hooks/pre-receive`.
|
||||||
|
|
||||||
### Key rotation
|
### Key rotation
|
||||||
|
|
||||||
`relicario org rotate-key` generates a fresh 256-bit org master key,
|
`relicario org rotate-key` generates a fresh 256-bit org master key,
|
||||||
|
|||||||
Reference in New Issue
Block a user