crates/gunnar-ui-server/src/api/policy.rs
//! The pre-receive gate's configuration: branch protection, ref-name policy,
//! and the gate list.
//!
//! gunnar has served all six `Policy` RPCs for some time — the rules live in a
//! document at `refs/gunnar/policy` and `gunnar-policy` runs them between "pack
//! is indexed" and "refs are applied". Nothing on this side could reach them,
//! so the console rendered branch protection as a disabled panel and an
//! operator had no way to turn the guard on. That is the whole reason this
//! module exists.
//!
//! # Two things this layer does that gunnar does not
//!
//! **It refuses a `ref_pattern` that is not a full ref name.** gunnar's pattern
//! is a git-style glob over `refs/heads/main`, not over `main`. A rule on
//! `main` is accepted by the store, listed back by `GetProtection`, counted as
//! configured by `ListGates` — and matches no reference, so it protects
//! nothing. A guard that reads as present and is not is worse than no guard,
//! and this is the one place that can say so before the rule is written.
//!
//! **The write routes are gated by the console's own role ladder, and that
//! ladder is the only gate there is.** gunnar requires `GrantLevel::Admin` for
//! `SetProtection`, `DeleteProtection` and `SetRefNamePolicy` — "turning the
//! pre-receive gate off for a ref is an ACL act, not a push" — but it checks
//! that against the CONSOLE's key, which holds a server-wide admin grant (see
//! [`crate::grpc::Fail::is_console_identity_problem`]). So gunnar's Admin check
//! cannot tell one browser session from another and will pass for every one of
//! them. What separates a viewer from an operator here is
//! [`crate::auth::middleware::role_floor`] and nothing else.
//!
//! The floor those three routes land on is **`Operator`**, from the default
//! rule for a mutation under `/api/` — deliberately, not by omission:
//!
//! * The ADMIN surfaces in that table are the identity ones (`/api/principals`,
//! `/api/keys`, `/api/grants`, `/api/namespaces`): handing out keys and grants
//! IS handing out the server. Branch protection hands out nothing; it is
//! repository configuration.
//! * Every neighbouring repository mutation already sits at `Operator` while
//! gunnar gates it at `Admin` too — `Repos.Delete`, `Repos.SetVisibility`,
//! `Repos.SetDefaultBranch`. Deleting the repository outright is strictly
//! more destructive than removing a protection rule from it, so putting
//! protection ABOVE delete would be an inconsistency dressed as a tightening.
//! * The table is by METHOD and PREFIX so that a new route cannot forget to opt
//! in. `ADMIN_SURFACES` is a list of path PREFIXES and these routes are
//! repo-scoped suffixes (`/api/repos/{ns}/{name}/protection`), so an Admin
//! floor would mean a second matching shape inside `role_floor` — a second
//! way for a route to be classified, which is exactly what that function
//! exists to prevent.
//!
//! What the floor must do, and does: a `Viewer` session is refused all three.
//! See `a_viewer_cannot_reach_any_protection_write` in
//! `crate::auth::middleware`.
use axum::extract::{Path, Query};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api::{repo_path, ApiError, ApiResult};
use crate::grpc::Fail;
use crate::pb;
/// One branch-protection rule, as the console shows it.
///
/// The booleans keep gunnar's own polarity rather than being flipped into
/// `forbid_*` here: the wire field is `allow_force_push`, and a layer that
/// renamed it would make the console's JSON disagree with the proto an operator
/// reads next to it. The double negative is undone once, in words, on the page.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Protection {
/// A git-style glob over FULL ref names: `refs/heads/main`,
/// `refs/heads/release/*`. Never a bare branch name.
pub ref_pattern: String,
/// False — the default — rejects a non-fast-forward update.
#[serde(default)]
pub allow_force_push: bool,
/// False — the default — rejects deleting a matching ref.
#[serde(default)]
pub allow_delete: bool,
/// Every new commit must carry a valid `sshsig` from a key registered to
/// its pusher.
#[serde(default)]
pub require_signature: bool,
/// Empty means "any principal with WRITE".
#[serde(default)]
pub allowed_principal_ids: Vec<String>,
/// 0 means no limit.
#[serde(default)]
pub max_object_bytes: u64,
/// 0 means no limit.
#[serde(default)]
pub max_objects_per_push: u64,
/// A merge into a matching ref does not count the pull request AUTHOR's
/// own approval. Off by default.
#[serde(default)]
pub forbid_self_approval: bool,
/// How many DISTINCT verified approvers of the exact tip a merge into a
/// matching ref needs. Approvals by the same principal count once. 0 keeps
/// gunnar's baseline: one verified approval.
#[serde(default)]
pub required_approvals: u32,
}
impl From<pb::BranchProtection> for Protection {
fn from(p: pb::BranchProtection) -> Protection {
Protection {
ref_pattern: p.ref_pattern,
allow_force_push: p.allow_force_push,
allow_delete: p.allow_delete,
require_signature: p.require_signature,
allowed_principal_ids: p.allowed_principal_ids,
max_object_bytes: p.max_object_bytes,
max_objects_per_push: p.max_objects_per_push,
forbid_self_approval: p.forbid_self_approval,
required_approvals: p.required_approvals,
}
}
}
impl From<Protection> for pb::BranchProtection {
fn from(p: Protection) -> pb::BranchProtection {
pb::BranchProtection {
ref_pattern: p.ref_pattern,
allow_force_push: p.allow_force_push,
allow_delete: p.allow_delete,
require_signature: p.require_signature,
allowed_principal_ids: p.allowed_principal_ids,
max_object_bytes: p.max_object_bytes,
max_objects_per_push: p.max_objects_per_push,
forbid_self_approval: p.forbid_self_approval,
required_approvals: p.required_approvals,
}
}
}
/// The ref-name allowlist and denylist. Deny wins.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RefNames {
#[serde(default)]
pub allow_patterns: Vec<String>,
#[serde(default)]
pub deny_patterns: Vec<String>,
}
/// One gate, and whether this repository has anything configured for it.
///
/// `enabled` is gunnar's answer and means "there is configuration this gate
/// could act on", not "this gate is compiled in". A gate with nothing
/// configured cannot reject anything.
#[derive(Debug, Clone, Serialize)]
pub struct Gate {
pub name: String,
pub description: String,
pub enabled: bool,
}
/// A ref pattern that is not a full ref name protects nothing.
///
/// gunnar matches the glob against `refs/heads/main`, so a rule on `main`
/// matches no reference at all — and is stored, listed back and counted as a
/// configured gate exactly like a working one. The refusal names the fix
/// rather than only the fault, because the fault is invisible by construction.
fn full_ref_name(pattern: &str) -> Result<(), ApiError> {
let pattern = pattern.trim();
if pattern.is_empty() {
return Err(ApiError(Fail {
code: "invalidargument".to_owned(),
message: "a ref pattern is required; a rule with no pattern matches nothing".to_owned(),
}));
}
if !pattern.starts_with("refs/") {
return Err(ApiError(Fail {
code: "invalidargument".to_owned(),
message: format!(
"{pattern:?} is not a full ref name, so it would match no reference and protect \
nothing; Gunnar globs over names like \"refs/heads/main\" — did you mean \
\"refs/heads/{pattern}\"?"
),
}));
}
Ok(())
}
/// `GET /api/repos/{namespace}/{name}/protection`
///
/// Every rule on the repository. An empty `ref_pattern` on the wire is
/// gunnar's "all of them", and this route never sends anything else — a single
/// rule is a filter of this list, not a second endpoint.
///
/// **Asked as the reader** (thin layer, owner 2026-10-04): gunnar answers the
/// policy reads under Read on the repository, so another account's branch
/// rules are refused by gunnar on the person's own key — a pusher holds Read
/// and still sees the gate that will reject them.
pub async fn list(
reader: crate::api::Reader,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Vec<Protection>> {
let response = reader
.gunnar
.policy()
.get_protection(pb::GetProtectionRequest {
repo: Some(repo_path(&namespace, &name)),
ref_pattern: String::new(),
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(
response
.protections
.into_iter()
.map(Protection::from)
.collect(),
))
}
/// `POST /api/repos/{namespace}/{name}/protection`
///
/// Upserted on `ref_pattern`: posting a pattern that already has a rule
/// replaces it, which is gunnar's own semantic and the reason the console has
/// no separate edit route.
pub async fn set(
actor: crate::api::Actor,
Path((namespace, name)): Path<(String, String)>,
Json(rule): Json<Protection>,
) -> ApiResult<Protection> {
full_ref_name(&rule.ref_pattern)?;
let mut rule = rule;
rule.ref_pattern = rule.ref_pattern.trim().to_owned();
rule.allowed_principal_ids
.retain(|id| !id.trim().is_empty());
let response = actor
.gunnar
.policy()
.set_protection(pb::SetProtectionRequest {
repo: Some(repo_path(&namespace, &name)),
protection: Some(rule.into()),
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(Protection::from(
response.protection.unwrap_or_default(),
)))
}
/// `?ref_pattern=` on a delete.
#[derive(Debug, Clone, Deserialize)]
pub struct WhichRule {
#[serde(default)]
pub ref_pattern: String,
}
/// `DELETE /api/repos/{namespace}/{name}/protection?ref_pattern=…`
///
/// The pattern rides in the QUERY STRING, not in a path segment, and that is
/// forced rather than stylistic: every pattern worth writing contains slashes
/// (`refs/heads/main` has two), and axum 0.8's `{pattern}` matches a single
/// segment — a `DELETE …/protection/refs/heads/main` matches no route at all
/// and answers 404. The wildcard `{*pattern}` would match, but it also has to
/// be the last segment and it swallows the encoding: a pattern is a glob, and
/// `*` in a path is not the same character it is in a query value. Same
/// reasoning as the object and browse routes above, which put an oid and a file
/// path in the query for their own reasons.
pub async fn delete(
actor: crate::api::Actor,
Path((namespace, name)): Path<(String, String)>,
Query(which): Query<WhichRule>,
) -> ApiResult<super::repos::Done> {
// Not `full_ref_name`: a rule that was somehow stored under a bare name has
// to remain deletable, or the guard against writing one would also be a
// guard against cleaning one up. Only emptiness is refused here, and gunnar
// refuses that too.
if which.ref_pattern.trim().is_empty() {
return Err(ApiError(Fail {
code: "invalidargument".to_owned(),
message: "name the rule to remove with ?ref_pattern=".to_owned(),
}));
}
actor
.gunnar
.policy()
.delete_protection(pb::DeleteProtectionRequest {
repo: Some(repo_path(&namespace, &name)),
ref_pattern: which.ref_pattern,
})
.await
.map_err(ApiError::from)?;
Ok(Json(super::repos::Done { ok: true }))
}
/// `GET /api/repos/{namespace}/{name}/ref-names`
///
/// Asked as the reader, as [`list`] is.
pub async fn ref_names(
reader: crate::api::Reader,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<RefNames> {
let response = reader
.gunnar
.policy()
.get_ref_name_policy(pb::GetRefNamePolicyRequest {
repo: Some(repo_path(&namespace, &name)),
})
.await
.map_err(ApiError::from)?
.into_inner();
let policy = response.policy.unwrap_or_default();
Ok(Json(RefNames {
allow_patterns: policy.allow_patterns,
deny_patterns: policy.deny_patterns,
}))
}
/// `PUT /api/repos/{namespace}/{name}/ref-names`
///
/// A PUT because gunnar's `SetRefNamePolicy` replaces the whole document —
/// there is no add-one-pattern RPC — and a POST would read as an append. Empty
/// lists are meaningful and are sent as such: that is how a policy is cleared.
pub async fn set_ref_names(
actor: crate::api::Actor,
Path((namespace, name)): Path<(String, String)>,
Json(policy): Json<RefNames>,
) -> ApiResult<RefNames> {
let tidy = |list: Vec<String>| -> Vec<String> {
list.into_iter()
.map(|p| p.trim().to_owned())
.filter(|p| !p.is_empty())
.collect()
};
let response = actor
.gunnar
.policy()
.set_ref_name_policy(pb::SetRefNamePolicyRequest {
repo: Some(repo_path(&namespace, &name)),
policy: Some(pb::RefNamePolicy {
allow_patterns: tidy(policy.allow_patterns),
deny_patterns: tidy(policy.deny_patterns),
}),
})
.await
.map_err(ApiError::from)?
.into_inner();
let policy = response.policy.unwrap_or_default();
Ok(Json(RefNames {
allow_patterns: policy.allow_patterns,
deny_patterns: policy.deny_patterns,
}))
}
/// `GET /api/repos/{namespace}/{name}/gates`
///
/// Asked as the reader, as [`list`] is.
pub async fn gates(
reader: crate::api::Reader,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Vec<Gate>> {
let response = reader
.gunnar
.policy()
.list_gates(pb::ListGatesRequest {
repo: Some(repo_path(&namespace, &name)),
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(
response
.gates
.into_iter()
.map(|g| Gate {
name: g.name,
description: g.description,
enabled: g.enabled,
})
.collect(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_branch_name_is_refused_and_the_refusal_names_the_fix() {
// The red this guard exists for: `main` is accepted by the store,
// listed back by GetProtection and counted by ListGates, and matches no
// reference. Without this the console would show a protection rule that
// protects nothing.
let refused = full_ref_name("main").expect_err("a bare branch name must be refused");
assert_eq!(refused.0.code, "invalidargument");
assert_eq!(refused.0.http_status(), http::StatusCode::BAD_REQUEST);
assert!(
refused.0.message.contains("\"refs/heads/main\""),
"the refusal has to name the fix, not just the fault: {}",
refused.0.message
);
}
#[test]
fn an_empty_pattern_is_refused_before_it_reaches_gunnar() {
let refused = full_ref_name(" ").expect_err("an empty pattern must be refused");
assert_eq!(refused.0.code, "invalidargument");
assert!(refused.0.message.contains("required"));
}
#[test]
fn a_full_ref_glob_is_accepted() {
// The identity check on the other side of the guard: if this failed the
// two above would pass for the wrong reason.
full_ref_name("refs/heads/main").expect("a full ref name is what gunnar globs over");
full_ref_name("refs/heads/release/*").expect("a glob over full ref names is the point");
full_ref_name("refs/tags/*").expect("tags are refs too");
}
#[test]
fn the_wire_shape_survives_a_round_trip_with_its_polarity_intact() {
// Not a state round-trip for its own sake: the failure this catches is a
// field crossed with its neighbour, which is invisible in a shape test
// where every bool is the same value. So they are not.
let rule = Protection {
ref_pattern: "refs/heads/main".to_owned(),
allow_force_push: false,
allow_delete: true,
require_signature: true,
allowed_principal_ids: vec!["alice".to_owned()],
max_object_bytes: 1024,
max_objects_per_push: 0,
// The polarity trap this test is about: a bool crossed with its
// neighbour must be visible, so this one differs from its
// fellow bools' pattern too.
forbid_self_approval: true,
required_approvals: 2,
};
let wire: pb::BranchProtection = rule.clone().into();
assert!(!wire.allow_force_push);
assert!(wire.allow_delete);
assert!(wire.require_signature);
assert_eq!(wire.max_object_bytes, 1024);
assert_eq!(wire.max_objects_per_push, 0);
assert!(wire.forbid_self_approval);
assert_eq!(wire.required_approvals, 2);
let back = Protection::from(wire);
assert_eq!(back.ref_pattern, rule.ref_pattern);
assert_eq!(back.allow_force_push, rule.allow_force_push);
assert_eq!(back.allow_delete, rule.allow_delete);
assert_eq!(back.allowed_principal_ids, rule.allowed_principal_ids);
assert_eq!(back.forbid_self_approval, rule.forbid_self_approval);
assert_eq!(back.required_approvals, rule.required_approvals);
}
/// ★ Why the delete takes its pattern in the query string.
///
/// Not a preference. Measured here rather than asserted in a comment,
/// because "axum probably cannot route that" is exactly the kind of belief
/// that ships a delete button which 404s on every rule worth deleting —
/// `refs/heads/main` has two slashes and every useful pattern has at least
/// two.
#[tokio::test]
async fn a_slash_bearing_pattern_does_not_survive_a_path_segment() {
use tower::ServiceExt;
async fn ask(app: axum::Router, uri: &str) -> http::StatusCode {
let request = http::Request::builder()
.method(http::Method::DELETE)
.uri(uri)
.body(axum::body::Body::empty())
.expect("a request");
app.oneshot(request)
.await
.expect("the router answers")
.status()
}
// The shape this module did NOT take.
let segmented = axum::Router::new().route(
"/api/repos/{namespace}/{name}/protection/{pattern}",
axum::routing::delete(|| async { "matched" }),
);
assert_eq!(
ask(
segmented.clone(),
"/api/repos/ns/repo/protection/refs/heads/main"
)
.await,
http::StatusCode::NOT_FOUND,
"a slash-bearing ref pattern must NOT match a single path segment; if this ever \
passes, the query-string design below is no longer forced"
);
// And it is the slashes, not the route: one bare segment matches fine,
// which is the identity value this test would otherwise sit on.
assert_eq!(
ask(segmented, "/api/repos/ns/repo/protection/main").await,
http::StatusCode::OK
);
// The shape this module took.
let queried = axum::Router::new().route(
"/api/repos/{namespace}/{name}/protection",
axum::routing::delete(|Query(which): Query<WhichRule>| async move {
assert_eq!(which.ref_pattern, "refs/heads/main");
"matched"
}),
);
assert_eq!(
ask(
queried,
"/api/repos/ns/repo/protection?ref_pattern=refs%2Fheads%2Fmain"
)
.await,
http::StatusCode::OK,
"the pattern has to arrive intact through the query string"
);
}
#[test]
fn a_rule_body_may_omit_every_field_but_the_pattern() {
// The console posts a rule with no principal list and no size caps, and
// proto3 defaults are what "no limit" and "any principal with write"
// are spelled as. A missing field must not be a deserialise error.
let rule: Protection =
serde_json::from_str(r#"{"ref_pattern":"refs/heads/main"}"#).expect("defaults apply");
assert!(!rule.allow_force_push);
assert!(rule.allowed_principal_ids.is_empty());
assert_eq!(rule.max_object_bytes, 0);
}
}