git-ents.gitmain
⌘K
foforge
commit 3333d5e
feat: gate ref pushes by member role

Members default to pushing every ref; a role’s allow/deny glob lists in refs/meta/config opt a role into per-ref restrictions, checked against the signer identified from the push certificate.

feat: add role field to Member feat: add roles map and glob-matched ref_allowed to Config feat: identify the signing member and gate ref updates in pre_receive feat: add --role flag to git ents members add Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-ents-server/src/verify.rs @@ -10,10 +10,11 @@ //! subtracted from the trust set before the check so a revoked key fails the //! moment it is listed, faster than its window would expire. -use std::io::Write; +use std::io::{Read, Write}; use std::path::Path; use std::process::{Command, Stdio}; +use git_ents::config; use git_ents::members::{self, Member}; use git_ents::revocations; @@ -35,6 +36,7 @@ let revoked = revocations::fingerprints_with(&store) .map_err(|e| format!("could not read revocations: {e}"))?; let authorized = members::without_revoked(members, &revoked); + let ref_updates = read_ref_updates()?; let cert_oid = env("GIT_PUSH_CERT") .filter(|oid| !oid.is_empty()) @@ -46,7 +48,47 @@ } let certificate = cat_blob(&repo, &cert_oid)?; - verify_certificate(&authorized, &certificate) + verify_certificate(&authorized, &certificate)?; + + let signer = identify_signer(&authorized, &certificate); + if let Some(member) = signer { + let config = + config::load_with(&store).map_err(|e| format!("could not read configuration: {e}"))?; + for ref_name in &ref_updates { + if !config::ref_allowed(&config, member.role.as_deref(), ref_name) { + return Err(format!( + "{} (role {:?}) is not permitted to push to {ref_name:?}", + member.principal, member.role + )); + } + } + } + Ok(()) +} + +/// The ref names git is about to update, read from the hook's own stdin +/// (`<old-oid> <new-oid> <refname>` per line) — distinct from the certificate +/// payload, which is written to a separate `ssh-keygen` child process below. +fn read_ref_updates() -> Result<Vec<String>, String> { + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .map_err(|e| format!("could not read ref updates: {e}"))?; + Ok(input + .lines() + .filter_map(|line| line.split_whitespace().nth(2)) + .map(str::to_owned) + .collect()) +} + +/// Which of `authorized` signed `certificate`, by re-checking the signature +/// against each member's own key set individually. `verify_certificate` +/// already established the signature matches *someone* in `authorized`; this +/// narrows it to a specific member so their `role` can gate the ref update. +fn identify_signer<'a>(authorized: &'a [Member], certificate: &str) -> Option<&'a Member> { + authorized + .iter() + .find(|member| verify_certificate(std::slice::from_ref(member), certificate).is_ok()) } /// Split the certificate into its signed payload and SSH signature, then accept
crates/git-ents/src/config.rs @@ -8,6 +8,7 @@ //! cannot rewrite the repository's metadata, and the metadata carries its own //! independent history. +use std::collections::BTreeMap; use std::path::Path; use facet::Facet; @@ -25,6 +26,70 @@ /// The repository's topics, members-gated metadata rather than worktree /// content. pub topics: Vec<String>, + /// Ref-push rules keyed by role name, matched against a pushing + /// [`crate::members::Member`]'s `role`. A role absent here — or a member + /// with no role at all — permits every ref: role rules are opt-in gating + /// layered on top of that default-allow-all rule. + pub roles: BTreeMap<String, RoleRules>, +} + +/// The ref-push rules for one role: glob patterns (`*` matches any run of +/// characters) matched against the full ref name (e.g. `refs/heads/*`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)] +pub struct RoleRules { + /// Refs this role may push to. Empty means "every ref not denied" — + /// otherwise a ref must match at least one pattern here. + pub allow: Vec<String>, + /// Refs this role may never push to, checked before `allow`. + pub deny: Vec<String>, +} + +/// Whether `role`'s rules in `config` permit pushing to `ref_name`. `role` +/// being `None`, or naming a role absent from `config.roles`, permits every +/// ref — see [`Config::roles`]. +#[must_use] +pub fn ref_allowed(config: &Config, role: Option<&str>, ref_name: &str) -> bool { + let Some(role) = role else { + return true; + }; + let Some(rules) = config.roles.get(role) else { + return true; + }; + if rules + .deny + .iter() + .any(|pattern| glob_match(pattern, ref_name)) + { + return false; + } + rules.allow.is_empty() + || rules + .allow + .iter() + .any(|pattern| glob_match(pattern, ref_name)) +} + +/// Whether `text` matches `pattern`, where `*` in `pattern` matches any run of +/// characters (including none, and including `/`). +#[must_use] +pub fn glob_match(pattern: &str, text: &str) -> bool { + fn go(pattern: &[u8], text: &[u8]) -> bool { + match pattern.split_first() { + None => text.is_empty(), + Some((b'*', rest)) => { + go(rest, text) + || match text.split_first() { + Some((_, t_rest)) => go(pattern, t_rest), + None => false, + } + } + Some((c, rest)) => match text.split_first() { + Some((t, t_rest)) if t == c => go(rest, t_rest), + _ => false, + }, + } + } + go(pattern.as_bytes(), text.as_bytes()) } /// Load the configuration recorded at [`CONFIG_REF`] from an already-open @@ -74,6 +139,7 @@ description: "A repository".to_owned(), homepage: "https://example.com".to_owned(), topics: vec!["rust".to_owned(), "git".to_owned()], + roles: BTreeMap::new(), } } @@ -118,4 +184,53 @@ assert_eq!(load(&repo).unwrap(), Config::default()); let _ = std::fs::remove_dir_all(&repo); } + + #[test] + fn glob_match_supports_a_trailing_star() { + assert!(glob_match("refs/heads/*", "refs/heads/main")); + assert!(glob_match("refs/heads/*", "refs/heads/")); + assert!(!glob_match("refs/heads/*", "refs/tags/v1")); + assert!(glob_match("*", "anything")); + } + + #[test] + fn ref_allowed_defaults_to_true_with_no_role_or_unlisted_role() { + let mut config = Config::default(); + config.roles.insert( + "readonly".to_owned(), + RoleRules { + allow: vec![], + deny: vec!["refs/heads/*".to_owned()], + }, + ); + assert!(ref_allowed(&config, None, "refs/heads/main")); + assert!(ref_allowed(&config, Some("nonexistent"), "refs/heads/main")); + } + + #[test] + fn ref_allowed_checks_deny_before_allow() { + let mut config = Config::default(); + config.roles.insert( + "release-manager".to_owned(), + RoleRules { + allow: vec!["refs/heads/release-*".to_owned()], + deny: vec!["refs/heads/release-locked".to_owned()], + }, + ); + assert!(ref_allowed( + &config, + Some("release-manager"), + "refs/heads/release-1.0" + )); + assert!(!ref_allowed( + &config, + Some("release-manager"), + "refs/heads/release-locked" + )); + assert!(!ref_allowed( + &config, + Some("release-manager"), + "refs/heads/main" + )); + } }
crates/git-ents/src/main.rs @@ -114,6 +114,11 @@ /// account create` prints one). #[facet(args::named, args::label = "GENESIS_HASH")] account: Option<String>, + /// Role to gate this member's pushes by, matched against + /// `refs/meta/config`'s role rules. Omit for no role (every ref + /// allowed). + #[facet(args::named)] + role: Option<String>, }, /// Remove a member, deleting its ref on a remote and pushing the update. Remove { @@ -305,6 +310,7 @@ valid_after, valid_before, account, + role, } => members_add( username, remote, @@ -313,6 +319,7 @@ valid_after, valid_before, account, + role, ), Action::Remove { username } => members_remove(&username, remote), Action::Revoke { @@ -1005,6 +1012,10 @@ /// Authorize a key (or pin a CA) for the member `username` on `remote`, trusting /// the member within the given validity window, and push the updated member ref. +#[expect( + clippy::too_many_arguments, + reason = "each argument is an independent, optional member field set from its own CLI flag" +)] fn members_add( username: Option<String>, remote: &str, @@ -1013,6 +1024,7 @@ valid_after: Option<String>, valid_before: Option<String>, account: Option<String>, + role: Option<String>, ) -> Result<(), String> { let username = interactive::text_or(username, "Username")?; let (key, cert_authority) = resolve_trust(key, cert_authority)?; @@ -1042,6 +1054,9 @@ if account.is_some() { member.account = account; } + if role.is_some() { + member.role = role; + } // Pinning a CA replaces the member's trust wholesale — a member is either // leaf keys or a CA, never both.
crates/git-ents/src/members.rs @@ -79,6 +79,13 @@ /// entry, so a member ref written before this field existed keeps /// loading unchanged. pub account: Option<String>, + /// The member's role, matched against `refs/meta/config`'s + /// [`crate::config::Config::roles`] to gate which refs they may push to. + /// `None` (or a role absent from that map) permits every ref — role + /// rules are opt-in gating layered on top of the default-allow-all rule. + /// Plain `Option`, so a member ref written before this field existed + /// keeps loading unchanged. + pub role: Option<String>, } /// Whether a member was admin-registered or self-attested via web onboarding. @@ -154,6 +161,7 @@ trust: Trust::Keys(keys), provenance: Provenance::AdminRegistered, account: None, + role: None, } } @@ -168,6 +176,7 @@ trust: Trust::CertAuthority(ca), provenance: Provenance::AdminRegistered, account: None, + role: None, } } @@ -182,6 +191,7 @@ trust: Trust::WebAuthn(keys), provenance: Provenance::SelfAttestedWeb, account: None, + role: None, } }