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