gunnar-grpc 3.0.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 one exception, and why it is not a `Sign` RPC** (GHUNT-19): a person
//! in a browser holds an identity-core passkey session and no key of theirs, so
//! their approval cannot carry their own sshsig. An UNSIGNED event from such a
//! session is taken only when the session stepped up — a fresh, user-verified
//! touch of the person's own passkey within the core's window — and gunnar then
//! signs it with its own forge-attestation key, never a principal's, the actor
//! named and "attested for session step-up at <time>" inside the signature and
//! in the audit row. A reader sees whose word it is: the actor's own key, or
//! gunnar's attestation of their touch. Nobody can ask for an attestation for
//! somebody else, and without the touch the call is refused by name
//! (`[stepup]`).
//!
//! 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 session = request
            .extensions()
            .get::<crate::auth::PersonSession>()
            .copied();
        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
            )));
        }

        if signed.attested.is_some() {
            return Err(Status::invalid_argument(
                "this event carries an attestation, and an attestation is gunnar's own word: only \
                 gunnar makes one, for a person's session that stepped up",
            ));
        }

        // **An UNSIGNED event is a request for gunnar's attestation**
        // (GHUNT-19). A person in a browser holds a passkey session and no key
        // gunnar could check a signature against, so their approval cannot be
        // their own sshsig. What gunnar CAN check is that this session's person
        // touched their own passkey, user-verified, inside the identity core's
        // window — and then it signs the event itself, the actor named and the
        // step-up inside the signature. Anything less is refused by name, so the
        // console can answer with the touch in place.
        if signed.key_fingerprint.is_empty() && signed.signature.is_empty() {
            let Some(session) = session else {
                return Err(Status::invalid_argument(
                    "this event is unsigned: sign it with your own registered key, or send it \
                     from your own passkey session after a fresh touch, which gunnar attests",
                ));
            };
            let repo = event.repo.clone();
            let pull = event.pull;
            if event.actor.is_empty() || event.actor != caller.principal_id {
                return Err(Status::permission_denied(format!(
                    "gunnar attests an event only for the session's own person ({:?}), and this \
                     one is attributed to {:?}",
                    caller.principal_id, event.actor
                )));
            }
            if !session.stepped_up {
                tracing::warn!(
                    target: crate::auth::AUDIT_TARGET,
                    event = "forge.step_up_refused",
                    principal = %caller.principal_id,
                    repo = %repo,
                    pull,
                    "a forge event came from no stepped-up session"
                );
                return Err(Status::permission_denied(
                    "[stepup] Forge.Append needs a fresh touch of your own passkey (user-verified, \
                     within the last 10 minutes)",
                ));
            }
            let at = session
                .stepped_up_at_unix_ms
                .unwrap_or_else(crate::bus::now_unix_ms);
            let (index, words) = self.ctx.control.forge_append_attested(signed, at)?;
            tracing::info!(
                target: crate::auth::AUDIT_TARGET,
                event = "forge.attested",
                principal = %caller.principal_id,
                repo = %repo,
                pull,
                index,
                attestation = %words,
                "gunnar attested a person's forge event for their stepped-up session"
            );
            let namespace = repo.split_once('/').map(|(ns, _)| ns).unwrap_or_default();
            self.ctx.account_event(
                self.ctx.account_of_namespace(namespace),
                crate::enterprise::AccountAuditEvent {
                    kind: "forge.attested".to_owned(),
                    actor: caller.principal_id.clone(),
                    actor_machine: false,
                    target: format!("{repo}#{pull}"),
                    detail: words,
                    refused: false,
                },
            );
            return Ok(Response::new(AppendEventResponse { index }));
        }

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