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

nordisk/gunnar

crates/gunnar-grpc/src/service/policy.rs

//! `Policy`, the pre-receive gate's configuration.
//!
//! This service only *stores* the rules. `gunnar-policy` runs them between
//! "pack is indexed" and "refs are applied", and a rejection there becomes a
//! `PushRejected` event (the event stream). Measure, never assert applies
//! hardest to the gates themselves: every gate must be seen to REJECT a crafted
//! bad push before it is trusted to allow a good one. That test belongs with the
//! gate, not here.

use gunnar_proto::policy_server::Policy;
use gunnar_proto::{
    DeleteProtectionRequest, DeleteProtectionResponse, GateInfo, GetProtectionRequest,
    GetProtectionResponse, GetRefNamePolicyRequest, GetRefNamePolicyResponse, ListGatesRequest,
    ListGatesResponse, SetProtectionRequest, SetProtectionResponse, SetRefNamePolicyRequest,
    SetRefNamePolicyResponse,
};
use tonic::{Request, Response, Status};

use crate::auth::Access;
use crate::service::PolicyService;
use crate::status::{opt_str, repo_path, require};
use gunnar_proto::GrantLevel;

#[tonic::async_trait]
impl Policy for PolicyService {
    async fn get_protection(
        &self,
        request: Request<GetProtectionRequest>,
    ) -> Result<Response<GetProtectionResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();
        let path = repo_path(request.repo, "repo")?;
        self.ctx.require(
            &caller,
            Access::repo(&path),
            GrantLevel::Read,
            "Policy.GetProtection",
        )?;
        let protections = self
            .ctx
            .control
            .get_protection(&path, opt_str(&request.ref_pattern))?;
        Ok(Response::new(GetProtectionResponse { protections }))
    }

    async fn set_protection(
        &self,
        request: Request<SetProtectionRequest>,
    ) -> Result<Response<SetProtectionResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();
        let path = repo_path(request.repo, "repo")?;
        // Turning the pre-receive gate off for a ref is an ACL act, not a
        // push: Admin, never Write.
        self.ctx.require(
            &caller,
            Access::repo(&path),
            GrantLevel::Admin,
            "Policy.SetProtection",
        )?;
        let protection = require(request.protection, "protection")?;
        let stored = self.ctx.control.set_protection(&path, protection)?;
        self.ctx.account_event(
            self.ctx.account_of_namespace(&path.namespace),
            crate::enterprise::AccountAuditEvent::by(
                &caller,
                "protection.set",
                format!("{}/{}", path.namespace, path.repo),
            )
            .detail(format!("{:?}", stored.ref_pattern)),
        );
        Ok(Response::new(SetProtectionResponse {
            protection: Some(stored),
        }))
    }

    async fn delete_protection(
        &self,
        request: Request<DeleteProtectionRequest>,
    ) -> Result<Response<DeleteProtectionResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();
        let path = repo_path(request.repo, "repo")?;
        self.ctx.require(
            &caller,
            Access::repo(&path),
            GrantLevel::Admin,
            "Policy.DeleteProtection",
        )?;
        if request.ref_pattern.is_empty() {
            return Err(Status::invalid_argument("`ref_pattern` is required"));
        }
        self.ctx
            .control
            .delete_protection(&path, &request.ref_pattern)?;
        self.ctx.account_event(
            self.ctx.account_of_namespace(&path.namespace),
            crate::enterprise::AccountAuditEvent::by(
                &caller,
                "protection.deleted",
                format!("{}/{}", path.namespace, path.repo),
            )
            .detail(format!("{:?}", request.ref_pattern)),
        );
        Ok(Response::new(DeleteProtectionResponse {}))
    }

    async fn get_ref_name_policy(
        &self,
        request: Request<GetRefNamePolicyRequest>,
    ) -> Result<Response<GetRefNamePolicyResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let path = repo_path(request.into_inner().repo, "repo")?;
        self.ctx.require(
            &caller,
            Access::repo(&path),
            GrantLevel::Read,
            "Policy.GetRefNamePolicy",
        )?;
        let policy = self.ctx.control.get_ref_name_policy(&path)?;
        Ok(Response::new(GetRefNamePolicyResponse {
            policy: Some(policy),
        }))
    }

    async fn set_ref_name_policy(
        &self,
        request: Request<SetRefNamePolicyRequest>,
    ) -> Result<Response<SetRefNamePolicyResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();
        let path = repo_path(request.repo, "repo")?;
        self.ctx.require(
            &caller,
            Access::repo(&path),
            GrantLevel::Admin,
            "Policy.SetRefNamePolicy",
        )?;
        let policy = require(request.policy, "policy")?;
        let stored = self.ctx.control.set_ref_name_policy(&path, policy)?;
        self.ctx.account_event(
            self.ctx.account_of_namespace(&path.namespace),
            crate::enterprise::AccountAuditEvent::by(
                &caller,
                "protection.ref_names",
                format!("{}/{}", path.namespace, path.repo),
            ),
        );
        Ok(Response::new(SetRefNamePolicyResponse {
            policy: Some(stored),
        }))
    }

    async fn list_gates(
        &self,
        request: Request<ListGatesRequest>,
    ) -> Result<Response<ListGatesResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let path = repo_path(request.into_inner().repo, "repo")?;
        self.ctx.require(
            &caller,
            Access::repo(&path),
            GrantLevel::Read,
            "Policy.ListGates",
        )?;
        // The repo must exist; otherwise this would answer for a repo that
        // does not, which is the shape of a lie a UI would render.
        self.ctx.control.get_repo(&path)?;

        let protections = self.ctx.control.get_protection(&path, None)?;
        let ref_policy = self.ctx.control.get_ref_name_policy(&path)?;
        let ref_policy_configured =
            !ref_policy.allow_patterns.is_empty() || !ref_policy.deny_patterns.is_empty();

        // The v1 gate set of the pre-receive gate. `enabled` reflects whether the repo actually
        // has configuration for that gate. A gate with nothing configured
        // cannot reject anything, and saying otherwise would misreport the
        // server's real behaviour.
        let live = self.ctx.control.live_gates(&path)?;
        let mut gates = vec![
            GateInfo {
                name: "branch-protection".into(),
                description: "no force-push, no delete, required signature, allowed \
                                  principals, per ref pattern"
                    .into(),
                enabled: !protections.is_empty(),
            },
            GateInfo {
                name: "ref-name-policy".into(),
                description: "a pattern allowlist/denylist over ref names".into(),
                enabled: ref_policy_configured,
            },
            GateInfo {
                name: "signed-commits".into(),
                description: "verify gpg.format=ssh signatures against the pusher's \
                                  registered keys"
                    .into(),
                enabled: protections.iter().any(|p| p.require_signature),
            },
            GateInfo {
                name: "size-limits".into(),
                description: "reject an oversized blob or an oversized push before it lands".into(),
                enabled: protections
                    .iter()
                    .any(|p| p.max_object_bytes > 0 || p.max_objects_per_push > 0),
            },
        ];
        // A plane that knows the live gates (the server's) has the last word: a gate the push
        // path runs is ON here, and the gates outside the v1 set are listed too, so a list
        // that says "nothing is enforced" can no longer sit beside a push that is refused.
        if let Some(live) = live {
            let on = |name: &str| live.iter().any(|g| g == name);
            for gate in &mut gates {
                let engine_name = match gate.name.as_str() {
                    "ref-name-policy" => "ref-name",
                    "size-limits" => "limits",
                    other => other,
                };
                gate.enabled = gate.enabled || on(engine_name);
            }
            gates.push(GateInfo {
                name: "push-options".into(),
                description: "which `git push -o` options this repository accepts and demands"
                    .into(),
                enabled: on("push_options"),
            });
            gates.push(GateInfo {
                name: "ai-trailers".into(),
                description: "refuse a commit new to the forge carrying a machine-attribution \
                              trailer (on by default)"
                    .into(),
                enabled: on("ai-trailers"),
            });
            gates.push(GateInfo {
                name: "encrypted".into(),
                description: "an encrypted repository refuses plaintext content".into(),
                enabled: on("encrypted"),
            });
        }
        Ok(Response::new(ListGatesResponse { gates }))
    }
}