feat: gate ref pushes by member role
commit
3333d5efeat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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,
}
}