gunnar-grpc 1.5.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! `Forge`: the REVIEW on a pull request, as signed append-only events
//! (roadmap phase 1).
//!
//! # Why this is a separate service from `PullRequests`
//!
//! Because the two halves have different authorities. [`crate::service::pulls`]
//! owns the pull request as an OBJECT — its number, its refs, its state — under
//! the control store's compare-and-swap, which is what an object needs. This
//! owns what people SAID about it, which is what needs a signature, because the
//! value of "alice approved commit abc" is that a third party can check it
//! without trusting this server.
//!
//! Merging them would mean one of the two halves losing what makes it right: a
//! signed state machine nobody can compare-and-swap, or a compare-and-swappable
//! approval that is the server's word.
//!
//! # Why there is no `Sign` RPC
//!
//! Because the server must not be able to make one. Every write here arrives
//! already signed by the caller's own key, and all this service does with it is
//! authorise the caller, check the shape, and hand it to the seam — which
//! verifies the signature against the ACL key table before it appends anything.
//! A server that could sign an approval is a server that could manufacture one,
//! and then a review is a database row rather than evidence.
//!
//! The pull request number is inside the signature too, which is why there is
//! no numbering RPC here: `PullRequests.Create` assigns it, and a client signs
//! against the number that call returned.
//!
//! # The grant levels, and why `Append` is Write
//!
//! Reading a review is Read: it is part of the repository. Appending is
//! **Write** and not Admin — commenting and reviewing are what a contributor
//! does — and the narrower question of *whose* approval is sufficient to merge
//! is not answered here at all. `PullRequests.Merge` asks it, out of the same
//! events, because "this key is registered and this signature checks out" is a
//! fact and "this person may approve" is a policy.

use gunnar_proto::forge_server::Forge;
use gunnar_proto::{
    AppendEventRequest, AppendEventResponse, GetIssueRequest, GetIssueResponse, GetReviewRequest,
    GetReviewResponse, GrantLevel, ListIssuesRequest, ListIssuesResponse, ListReviewsRequest,
    ListReviewsResponse,
};
use tonic::{Request, Response, Status};

use crate::auth::Access;
use crate::service::ForgeService;
use crate::status::require;

/// `<namespace>/<repo>`, as the access scope it names.
///
/// The forge log is keyed by the repository *string* the events carry, because
/// that string is inside the signature and cannot be re-parsed into something
/// else without breaking it. The ACL still wants the two halves, so this is the
/// one place that splits them, and it refuses anything that is not exactly two
/// non-empty segments rather than guessing.
fn scope(repo: &str) -> Result<Access, Status> {
    let Some((namespace, name)) = repo.split_once('/') else {
        return Err(Status::invalid_argument(format!(
            "`repo` must be `<namespace>/<repo>`, got {repo:?}"
        )));
    };
    if namespace.is_empty() || name.is_empty() || name.contains('/') {
        return Err(Status::invalid_argument(format!(
            "`repo` must be `<namespace>/<repo>`, got {repo:?}"
        )));
    }
    Ok(Access::Repo(gunnar_proto::RepoPath {
        namespace: namespace.to_owned(),
        repo: name.to_owned(),
    }))
}

#[tonic::async_trait]
impl Forge for ForgeService {
    async fn append(
        &self,
        request: Request<AppendEventRequest>,
    ) -> Result<Response<AppendEventResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();
        let signed = require(request.event, "event")?;
        let event = signed
            .event
            .as_ref()
            .ok_or_else(|| Status::invalid_argument("`event.event` is required"))?;
        let access = scope(&event.repo)?;
        self.ctx
            .require(&caller, access, GrantLevel::Write, "Forge.Append")?;

        // **The actor must be the caller.** The signature already proves which
        // *key* wrote this, and the seam checks that; this checks that the
        // principal the event is attributed to is the one on the connection, so
        // an authenticated contributor cannot file an event in a colleague's
        // name. The two checks are not the same one: a principal can have
        // several keys, and a key can be registered and later moved.
        if !caller.principal_id.is_empty() && event.actor != caller.principal_id {
            return Err(Status::permission_denied(format!(
                "this event is attributed to {:?} and the caller is {:?}; a forge event is \
                 evidence about who did something, so it may not be filed in another \
                 principal's name",
                event.actor, caller.principal_id
            )));
        }

        let index = self.ctx.control.forge_append(signed)?;
        Ok(Response::new(AppendEventResponse { index }))
    }

    async fn get_review(
        &self,
        request: Request<GetReviewRequest>,
    ) -> Result<Response<GetReviewResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();
        let access = scope(&request.repo)?;
        self.ctx
            .require(&caller, access, GrantLevel::Read, "Forge.GetReview")?;
        let (state, events) = self.ctx.control.forge_review(&request.repo, request.pull)?;
        Ok(Response::new(GetReviewResponse {
            state: Some(state),
            events,
        }))
    }

    async fn list_reviews(
        &self,
        request: Request<ListReviewsRequest>,
    ) -> Result<Response<ListReviewsResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();
        let access = scope(&request.repo)?;
        self.ctx
            .require(&caller, access, GrantLevel::Read, "Forge.ListReviews")?;
        let reviews = self.ctx.control.forge_reviews(&request.repo)?;
        Ok(Response::new(ListReviewsResponse { reviews }))
    }

    async fn get_issue(
        &self,
        request: Request<GetIssueRequest>,
    ) -> Result<Response<GetIssueResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();
        let access = scope(&request.repo)?;
        self.ctx
            .require(&caller, access, GrantLevel::Read, "Forge.GetIssue")?;
        // `None` — nobody opened this number — is a real not-found, unlike a
        // review which always folds to something: whether an issue exists is
        // this fold's own question, so it answers it with a status rather than
        // an empty success a reader could mistake for an open issue.
        let (state, events) = self
            .ctx
            .control
            .forge_issue(&request.repo, request.number)?
            .ok_or_else(|| {
                Status::not_found(format!(
                    "no issue #{} in {}: nobody has opened one",
                    request.number, request.repo
                ))
            })?;
        Ok(Response::new(GetIssueResponse {
            state: Some(state),
            events,
        }))
    }

    async fn list_issues(
        &self,
        request: Request<ListIssuesRequest>,
    ) -> Result<Response<ListIssuesResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        let request = request.into_inner();
        let access = scope(&request.repo)?;
        self.ctx
            .require(&caller, access, GrantLevel::Read, "Forge.ListIssues")?;
        let issues = self.ctx.control.forge_issues(&request.repo)?;
        Ok(Response::new(ListIssuesResponse { issues }))
    }
}