crates/gunnar-grpc/src/service/forge.rs
//! `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 }))
}
}