Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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);
    }
}