feat(server): grant-scope org attachment write paths in pre-receive hook

This commit is contained in:
adlee-was-taken
2026-06-20 17:19:52 -04:00
parent 517d52d517
commit d32af594e4
5 changed files with 81 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-server"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
description = "Pre-receive Git hook for relicario password manager"
license = "GPL-3.0-or-later"

View File

@@ -6,7 +6,8 @@
pub enum PathClass {
/// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
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 },
/// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
/// 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() };
}
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
}

View File

@@ -79,3 +79,43 @@ fn extract_schema_version_errors_on_missing_field() {
fn extract_schema_version_errors_on_garbage() {
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() }
);
}