feat(server): verify-org-commit — signature + path-scoped role/grant auth + owner-only elevation (parent-role authority) + schema monotonicity + generate-org-hook
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ use std::process::Command;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use relicario_core::device::{DeviceEntry, RevokedEntry};
|
use relicario_core::device::{DeviceEntry, RevokedEntry};
|
||||||
|
use relicario_core::org::{OrgCollections, OrgMember, OrgMembers, OrgRole};
|
||||||
|
use relicario_server::{classify_path, extract_schema_version, PathClass};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "relicario-server")]
|
#[command(name = "relicario-server")]
|
||||||
@@ -23,6 +25,13 @@ enum Commands {
|
|||||||
},
|
},
|
||||||
/// Generate a pre-receive hook script.
|
/// Generate a pre-receive hook script.
|
||||||
GenerateHook,
|
GenerateHook,
|
||||||
|
/// Verify a commit to an org vault: signature + role/path authorization.
|
||||||
|
VerifyOrgCommit {
|
||||||
|
/// The commit SHA to verify.
|
||||||
|
commit: String,
|
||||||
|
},
|
||||||
|
/// Generate an org pre-receive hook script.
|
||||||
|
GenerateOrgHook,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -31,6 +40,8 @@ fn main() -> Result<()> {
|
|||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::VerifyCommit { commit } => verify_commit(&commit),
|
Commands::VerifyCommit { commit } => verify_commit(&commit),
|
||||||
Commands::GenerateHook => generate_hook(),
|
Commands::GenerateHook => generate_hook(),
|
||||||
|
Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
|
||||||
|
Commands::GenerateOrgHook => generate_org_hook(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,3 +198,392 @@ fn git_show(commit: &str, path: &str) -> Result<String> {
|
|||||||
|
|
||||||
Ok(String::from_utf8(output.stdout)?)
|
Ok(String::from_utf8(output.stdout)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify the SSH signature on `commit` against the given org members and return
|
||||||
|
/// the matching member. On any failure (unsigned, malformed, or unknown signer)
|
||||||
|
/// this prints REJECT and calls `std::process::exit(1)`; it only returns on success.
|
||||||
|
fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember {
|
||||||
|
// Build a temp allowed-signers file from every current member's pubkey.
|
||||||
|
let tmp = match tempfile::tempdir() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — cannot create tempdir: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let allowed_path = tmp.path().join("allowed_signers");
|
||||||
|
let mut allowed_body = String::new();
|
||||||
|
for m in &members.members {
|
||||||
|
allowed_body.push_str("relicario ");
|
||||||
|
allowed_body.push_str(m.ed25519_pubkey.trim());
|
||||||
|
allowed_body.push('\n');
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::write(&allowed_path, &allowed_body) {
|
||||||
|
eprintln!("REJECT: org commit {commit} — cannot write allowed_signers: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run git verify-commit --raw with the allowed-signers file injected.
|
||||||
|
let output = match Command::new("git")
|
||||||
|
.args(["verify-commit", "--raw", commit])
|
||||||
|
.env("GIT_CONFIG_COUNT", "1")
|
||||||
|
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
||||||
|
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — git verify-commit failed to run: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|
||||||
|
// Parse the SHA-256 fingerprint from stderr (same regex as verify_commit).
|
||||||
|
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
|
||||||
|
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
||||||
|
Some(m) => m.as_str().to_string(),
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — no valid signature found (stderr: {})",
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map fingerprint → member via relicario_core::fingerprint over each pubkey.
|
||||||
|
for m in &members.members {
|
||||||
|
if let Ok(fp) = relicario_core::fingerprint(&m.ed25519_pubkey) {
|
||||||
|
if fp == signing_fp {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — signer (fingerprint {signing_fp}) is not a current org member"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_org_commit(commit: &str) -> Result<()> {
|
||||||
|
// Determine parent count from %P (space-separated parent SHAs; empty = root).
|
||||||
|
let parents_out = Command::new("git")
|
||||||
|
.args(["show", "-s", "--format=%P", commit])
|
||||||
|
.output()
|
||||||
|
.context("git show parents")?;
|
||||||
|
let parents_line = String::from_utf8_lossy(&parents_out.stdout);
|
||||||
|
let parents: Vec<&str> = parents_line.split_whitespace().collect();
|
||||||
|
|
||||||
|
// Merge commits are rejected. Org repos are linear (CLI uses pull --rebase).
|
||||||
|
if parents.len() > 1 {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — merge commits are not allowed in org vaults \
|
||||||
|
({} parents); rebase instead",
|
||||||
|
parents.len()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
let is_root = parents.is_empty();
|
||||||
|
|
||||||
|
// Load members.json AS OF THIS COMMIT so the genesis commit can authorize itself.
|
||||||
|
let members_json = match git_show(commit, "members.json") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
if is_root {
|
||||||
|
eprintln!("OK: org commit {commit} (root bootstrap - no members.json yet)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
eprintln!("REJECT: org commit {commit} — members.json missing from non-root commit");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let members: OrgMembers =
|
||||||
|
serde_json::from_str(&members_json).context("parse members.json")?;
|
||||||
|
if members.members.is_empty() {
|
||||||
|
if is_root {
|
||||||
|
eprintln!("OK: org commit {commit} (root bootstrap - empty member list)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
eprintln!("REJECT: org commit {commit} — members.json has no members");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
members
|
||||||
|
.validate()
|
||||||
|
.map_err(|e| anyhow::anyhow!("members.json invalid: {e}"))?;
|
||||||
|
|
||||||
|
// Verify the signature and resolve the signing member (exits on failure).
|
||||||
|
let signer = verify_org_signer(commit, &members);
|
||||||
|
|
||||||
|
// Enumerate changed paths. Root has no parent to diff, so use ls-tree.
|
||||||
|
let changed_paths: Vec<String> = if is_root {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args(["ls-tree", "-r", "--name-only", commit])
|
||||||
|
.output()
|
||||||
|
.context("git ls-tree")?;
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit])
|
||||||
|
.output()
|
||||||
|
.context("git diff-tree")?;
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Authorize each changed path against the signing member's role/grants.
|
||||||
|
// collections.json (as of this commit) is loaded lazily on the first item
|
||||||
|
// path, for the L5 slug-existence check.
|
||||||
|
let mut collection_slugs: Option<Vec<String>> = None;
|
||||||
|
for path in &changed_paths {
|
||||||
|
match classify_path(path) {
|
||||||
|
PathClass::Rejected(why) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — invalid path `{path}`: {why}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
PathClass::Protected => {
|
||||||
|
if !signer.role.can_manage_members() {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — member '{}' (role {:?}) may not write protected file `{path}`",
|
||||||
|
signer.display_name, signer.role
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
// Privilege-escalation gate: only an Owner may INTRODUCE or
|
||||||
|
// ELEVATE an owner/admin. An Admin may write members.json but
|
||||||
|
// must not mint owners/admins server-side (spec §148/158/271).
|
||||||
|
if path == "members.json" {
|
||||||
|
enforce_owner_only_elevation(commit, is_root, &members, &signer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathClass::Item { collection } => {
|
||||||
|
// The signing member must hold an explicit grant for the slug.
|
||||||
|
if !signer.collections.iter().any(|c| c == &collection) {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — member '{}' lacks a grant for collection `{collection}` (path `{path}`)",
|
||||||
|
signer.display_name
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
// Slug-existence (L5): the collection must exist in
|
||||||
|
// collections.json AS OF THIS COMMIT. A write into a
|
||||||
|
// granted-but-deleted (or never-created) collection is rejected.
|
||||||
|
let known = collection_slugs.get_or_insert_with(|| {
|
||||||
|
git_show(commit, "collections.json")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<OrgCollections>(&s).ok())
|
||||||
|
.map(|c| c.collections.into_iter().map(|d| d.slug).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
if !known.iter().any(|s| s == &collection) {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — item write to collection `{collection}` whose slug is absent from collections.json (path `{path}`)"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathClass::Unrestricted => {
|
||||||
|
// keys/<id>.enc, manifest.enc, etc. — signature check already passed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema-version monotonicity for the three JSON files (Task C2).
|
||||||
|
enforce_schema_monotonicity(commit, is_root, &changed_paths)?;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"OK: org commit {commit} verified — signed by '{}' ({:?}), {} path(s) authorized",
|
||||||
|
signer.display_name,
|
||||||
|
signer.role,
|
||||||
|
changed_paths.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject the commit unless every newly-introduced or elevated owner/admin is
|
||||||
|
/// authorized. The signer's AUTHORITY is their role in the PARENT state — the role
|
||||||
|
/// they held BEFORE this commit — NOT the role this commit may grant them. Reading
|
||||||
|
/// `signer.role` (which is parsed from the post-change members.json) would let an
|
||||||
|
/// admin self-promote to owner and then pass this very gate with the owner role
|
||||||
|
/// they are minting — the exact escalation H-C1 exists to stop. We diff the new
|
||||||
|
/// members.json against the parent's by member_id and require an owner-authority
|
||||||
|
/// signer for any member that BECOMES owner/admin (new entry, or a role elevated
|
||||||
|
/// up to owner/admin). On genesis (root) the sole bootstrap owner is allowed.
|
||||||
|
///
|
||||||
|
/// `git_show_parent` is defined alongside `enforce_schema_monotonicity` below.
|
||||||
|
fn enforce_owner_only_elevation(
|
||||||
|
commit: &str,
|
||||||
|
is_root: bool,
|
||||||
|
new_members: &OrgMembers,
|
||||||
|
signer: &OrgMember,
|
||||||
|
) {
|
||||||
|
let is_privileged = |r: OrgRole| matches!(r, OrgRole::Owner | OrgRole::Admin);
|
||||||
|
|
||||||
|
// Genesis: the bootstrap commit introduces the sole owner; allow it.
|
||||||
|
if is_root {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent baseline. If members.json did not exist in the parent, every
|
||||||
|
// privileged member here is "new" and must be owner-signed.
|
||||||
|
let parent_members: Vec<(String, OrgRole)> = match git_show_parent(commit, "members.json") {
|
||||||
|
Ok(s) => serde_json::from_str::<OrgMembers>(&s)
|
||||||
|
.map(|m| {
|
||||||
|
m.members
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.member_id.0, m.role))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
let parent_role = |id: &str| -> Option<OrgRole> {
|
||||||
|
parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The signer's authority = their PARENT role. A member absent from the parent
|
||||||
|
// (brand new) has no prior authority and cannot mint owners/admins.
|
||||||
|
let signer_parent = parent_role(signer.member_id.as_str());
|
||||||
|
let signer_may_manage_owners = signer_parent.map_or(false, |r| r.can_manage_owners());
|
||||||
|
|
||||||
|
for m in &new_members.members {
|
||||||
|
if !is_privileged(m.role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip ONLY if the role is unchanged from the parent (a no-op same-role
|
||||||
|
// entry). Any CHANGE into a privileged role — a new privileged member,
|
||||||
|
// Member→Admin/Owner, or Admin→Owner — must be owner-signed.
|
||||||
|
if parent_role(m.member_id.as_str()) == Some(m.role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A new owner/admin, or a member elevated to owner/admin → owner-only,
|
||||||
|
// judged by the signer's PRE-commit authority.
|
||||||
|
if !signer_may_manage_owners {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — member '{}' (parent role {:?}) may not introduce or \
|
||||||
|
elevate owner/admin '{}' to {:?}; only an owner may",
|
||||||
|
signer.display_name, signer_parent, m.display_name, m.role
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_org_hook() -> Result<()> {
|
||||||
|
print!(
|
||||||
|
r#"#!/bin/bash
|
||||||
|
# Relicario org pre-receive hook -- verify signatures + role/path authorization
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For each protected JSON file changed in this commit, ensure schema_version did
|
||||||
|
/// not decrease vs the parent commit, and re-validate collections.json structure.
|
||||||
|
fn enforce_schema_monotonicity(
|
||||||
|
commit: &str,
|
||||||
|
is_root: bool,
|
||||||
|
changed_paths: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
|
const VERSIONED: [&str; 3] = ["members.json", "collections.json", "org.json"];
|
||||||
|
|
||||||
|
for file in VERSIONED {
|
||||||
|
if !changed_paths.iter().any(|p| p == file) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deletion of a protected file is not allowed.
|
||||||
|
let new_content = match git_show(commit, file) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — protected file `{file}` was deleted; \
|
||||||
|
org vaults never delete {file}"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let new_version = match extract_schema_version(&new_content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — `{file}` invalid: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// collections.json structural validation.
|
||||||
|
if file == "collections.json" {
|
||||||
|
match serde_json::from_str::<relicario_core::org::OrgCollections>(&new_content) {
|
||||||
|
Ok(c) => {
|
||||||
|
if let Err(e) = c.validate() {
|
||||||
|
eprintln!("REJECT: org commit {commit} — collections.json invalid: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("REJECT: org commit {commit} — collections.json parse error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the root commit there is no parent baseline; any starting version is fine.
|
||||||
|
if is_root {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent version: if the file did not exist in the parent (newly added),
|
||||||
|
// there is no prior version to regress against — accept.
|
||||||
|
if let Ok(old_content) = git_show_parent(commit, file) {
|
||||||
|
let old_version = match extract_schema_version(&old_content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if new_version < old_version {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: org commit {commit} — `{file}` schema_version decreased \
|
||||||
|
({old_version} -> {new_version})"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file from a commit's FIRST PARENT tree: `git show {commit}^:{path}`.
|
||||||
|
fn git_show_parent(commit: &str, path: &str) -> Result<String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["show", &format!("{}^:{}", commit, path)])
|
||||||
|
.output()
|
||||||
|
.context("git show parent")?;
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!("git show {}^:{} failed", commit, path);
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8(output.stdout)?)
|
||||||
|
}
|
||||||
|
|||||||
152
crates/relicario-server/tests/org_hook_signed.rs
Normal file
152
crates/relicario-server/tests/org_hook_signed.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! Integration tests for `relicario-server verify-org-commit` privilege gating.
|
||||||
|
//!
|
||||||
|
//! H-C1: only an Owner may introduce or elevate an owner/admin. An Admin who
|
||||||
|
//! writes members.json must not be able to mint owners/admins.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use assert_cmd::Command as AssertCommand;
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use relicario_core::device::generate_keypair;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, String) {
|
||||||
|
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
||||||
|
let priv_path = dir.join(format!("{name}.key"));
|
||||||
|
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
}
|
||||||
|
(priv_path, pub_line)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git(repo: &Path, args: &[&str]) {
|
||||||
|
let status = Command::new("git").current_dir(repo).args(args).status().unwrap();
|
||||||
|
assert!(status.success(), "git {args:?} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// members.json content with two members; `member_id`s are fixed 16-hex.
|
||||||
|
fn members_json(owner_pub: &str, admin_pub: &str, admin_role: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{
|
||||||
|
"schema_version": 1,
|
||||||
|
"members": [
|
||||||
|
{{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
|
||||||
|
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }},
|
||||||
|
{{ "member_id": "2222222222222222", "display_name": "Admin", "role": "{admin_role}",
|
||||||
|
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
|
||||||
|
]
|
||||||
|
}}"#,
|
||||||
|
owner_pub.trim(),
|
||||||
|
admin_pub.trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage members.json, sign the commit with `signing_key`, return its SHA.
|
||||||
|
fn signed_members_commit(
|
||||||
|
repo: &Path,
|
||||||
|
signing_key: &Path,
|
||||||
|
allowed: &Path,
|
||||||
|
msg: &str,
|
||||||
|
content: &str,
|
||||||
|
) -> String {
|
||||||
|
fs::write(repo.join("members.json"), content).unwrap();
|
||||||
|
git(repo, &["add", "members.json"]);
|
||||||
|
let status = Command::new("git")
|
||||||
|
.current_dir(repo)
|
||||||
|
.args([
|
||||||
|
"-c", "gpg.format=ssh",
|
||||||
|
"-c", &format!("user.signingkey={}", signing_key.display()),
|
||||||
|
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
|
||||||
|
"commit", "-S", "-q", "-m", msg,
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(status.success());
|
||||||
|
let out = Command::new("git").current_dir(repo).args(["rev-parse", "HEAD"]).output().unwrap();
|
||||||
|
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up an org repo whose root commit (signed by the owner) registers an
|
||||||
|
/// owner + an admin. Returns (repo tmp, owner priv, admin priv, allowed file).
|
||||||
|
fn bootstrap() -> (TempDir, PathBuf, PathBuf, PathBuf) {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
git(repo, &["init", "-q", "-b", "main"]);
|
||||||
|
git(repo, &["config", "user.email", "t@t"]);
|
||||||
|
git(repo, &["config", "user.name", "t"]);
|
||||||
|
|
||||||
|
let (owner_priv, owner_pub) = write_keypair(repo, "owner");
|
||||||
|
let (admin_priv, admin_pub) = write_keypair(repo, "admin");
|
||||||
|
|
||||||
|
let allowed = repo.join("allowed_signers");
|
||||||
|
fs::write(
|
||||||
|
&allowed,
|
||||||
|
format!("relicario {}\nrelicario {}\n", owner_pub.trim(), admin_pub.trim()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Genesis: owner registers both members (admin starts as `admin`).
|
||||||
|
let genesis = members_json(&owner_pub, &admin_pub, "admin");
|
||||||
|
signed_members_commit(repo, &owner_priv, &allowed, "org-init", &genesis);
|
||||||
|
|
||||||
|
// also write org.json + collections.json so later commits are well-formed
|
||||||
|
fs::write(repo.join("org.json"),
|
||||||
|
r#"{"schema_version":1,"org_id":"abc0abc0abc0abc0","display_name":"Acme","created_at":0}"#).unwrap();
|
||||||
|
fs::write(repo.join("collections.json"), r#"{"schema_version":1,"collections":[]}"#).unwrap();
|
||||||
|
git(repo, &["add", "org.json", "collections.json"]);
|
||||||
|
// sign this housekeeping commit with the owner too
|
||||||
|
let _ = signed_members_commit(repo, &owner_priv, &allowed, "scaffold",
|
||||||
|
&members_json(&owner_pub, &admin_pub, "admin"));
|
||||||
|
|
||||||
|
(tmp, owner_priv, admin_priv, allowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admin_self_promote_to_owner_is_rejected() {
|
||||||
|
let (tmp, owner_priv, admin_priv, allowed) = bootstrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
let owner_pub = fs::read_to_string(repo.join("allowed_signers")).unwrap();
|
||||||
|
// Reconstruct pubkeys from the allowed_signers file (two "relicario <pub>" lines).
|
||||||
|
let lines: Vec<String> = owner_pub.lines()
|
||||||
|
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
|
||||||
|
let (op, ap) = (lines[0].clone(), lines[1].clone());
|
||||||
|
let _ = owner_priv;
|
||||||
|
|
||||||
|
// Admin signs a members.json that elevates THEMSELVES to owner.
|
||||||
|
let escalated = members_json(&op, &ap, "owner");
|
||||||
|
let sha = signed_members_commit(repo, &admin_priv, &allowed, "self-promote", &escalated);
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-org-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("only an owner"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn owner_promoting_an_admin_is_accepted() {
|
||||||
|
let (tmp, owner_priv, _admin_priv, allowed) = bootstrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
let allowed_body = fs::read_to_string(repo.join("allowed_signers")).unwrap();
|
||||||
|
let lines: Vec<String> = allowed_body.lines()
|
||||||
|
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
|
||||||
|
let (op, ap) = (lines[0].clone(), lines[1].clone());
|
||||||
|
|
||||||
|
// Owner signs a members.json that elevates the admin to owner — allowed.
|
||||||
|
let promoted = members_json(&op, &ap, "owner");
|
||||||
|
let sha = signed_members_commit(repo, &owner_priv, &allowed, "promote-admin", &promoted);
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-org-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user