crates/gunnar-ui-server/src/anonymous/repo_owner_writes.rs
//! **A repository's owner works on their own repository** (owner order
//! 2026-10-18, wide audience).
//!
//! Every ordinary person on a console is a `viewer`: the console role is the
//! SERVER-wide ladder, and a customer holds no server grant. Before this file
//! `role_floor` put every issue, pull request and branch-protection write at
//! `operator`, so the owner of `vera/ledger` could not open an issue on their
//! own repository — refused by the console before gunnar was asked.
//!
//! The rule now: each of these handlers signs AS THE PERSON
//! ([`crate::api::Actor`]) and gunnar decides on that repository's grants —
//! Write for issues and pull requests, Admin for branch protection. The floor
//! is a viewer's; the right is gunnar's, per repository.
//!
//! Proved three ways against the fake gunnar, which decides by the key that
//! signed the call (the account's owner holds everything in it): the owner
//! can, a stranger is refused by gunnar on their own key, and a visitor with
//! no session never reaches the handler.
use tonic::{Request, Response, Status};
use super::*;
use crate::role::Role;
impl FakeGunnar {
/// gunnar's per-repository rule, as the fake models it: the console's key
/// holds a server-wide grant; a principal holds everything in its own
/// account; nobody else holds a write.
fn may_write_in(&self, fingerprint: &str, namespace: &str) -> bool {
Self::granted(fingerprint)
|| self
.principal_of_key(fingerprint)
.is_some_and(|principal| principal == namespace)
}
fn write_or_refuse(&self, fingerprint: &str, namespace: &str, verb: &str) -> Result<(), Status> {
if self.may_write_in(fingerprint, namespace) {
Ok(())
} else {
Err(Status::permission_denied(format!(
"{verb}: principal has no write grant on {namespace}"
)))
}
}
fn opened(&self, repo: &str) -> Vec<pb::IssueState> {
self.issues
.lock()
.expect("the forge log")
.iter()
.filter(|i| i.repo == repo)
.cloned()
.collect()
}
}
#[tonic::async_trait]
impl crate::pb::forge_server::Forge for FakeGunnar {
async fn append(
&self,
request: Request<pb::AppendEventRequest>,
) -> Result<Response<pb::AppendEventResponse>, Status> {
let who = self.admits(&request)?;
let event = request
.into_inner()
.event
.and_then(|s| s.event)
.ok_or_else(|| Status::invalid_argument("`event` is required"))?;
let namespace = event.repo.split('/').next().unwrap_or_default().to_owned();
self.write_or_refuse(&who, &namespace, "Forge.Append")?;
if let Some(pb::forge_event::Kind::IssueOpened(opened)) = event.kind {
let mut issues = self.issues.lock().expect("the forge log");
let number = issues.iter().filter(|i| i.repo == event.repo).count() as u64 + 1;
issues.push(pb::IssueState {
repo: event.repo.clone(),
number,
title: opened.title,
body: opened.body,
author: event.actor.clone(),
open: true,
comments: 0,
opened_at_unix_ms: event.at_unix_ms,
});
}
Ok(Response::new(pb::AppendEventResponse { index: 1 }))
}
/// gunnar's rule (`Forge.ListIssues` takes Read on the repository, and Read
/// on a PUBLIC repository is everybody's, the grantless guest's too): the
/// fake's `may_read`, so a private repository's issues are refused to the
/// guest exactly as its files are (PUB-ISSUES).
async fn list_issues(
&self,
request: Request<pb::ListIssuesRequest>,
) -> Result<Response<pb::ListIssuesResponse>, Status> {
let who = self.admits(&request)?;
let repo = request.into_inner().repo;
self.may_read(&who, &issue_repo(&repo)?)?;
Ok(Response::new(pb::ListIssuesResponse { issues: self.opened(&repo) }))
}
async fn get_issue(
&self,
request: Request<pb::GetIssueRequest>,
) -> Result<Response<pb::GetIssueResponse>, Status> {
let who = self.admits(&request)?;
let asked = request.into_inner();
self.may_read(&who, &issue_repo(&asked.repo)?)?;
let state = self
.opened(&asked.repo)
.into_iter()
.find(|i| i.number == asked.number)
.ok_or_else(|| Status::not_found("no such issue"))?;
let events = self
.issue_threads
.lock()
.expect("the forge log")
.get(&(asked.repo.clone(), asked.number))
.cloned()
.unwrap_or_default()
.into_iter()
.map(|event| pb::SignedForgeEvent { event: Some(event), ..Default::default() })
.collect();
Ok(Response::new(pb::GetIssueResponse { state: Some(state), events }))
}
}
/// `<namespace>/<repo>` as the path gunnar's `scope` splits it into.
fn issue_repo(repo: &str) -> Result<pb::RepoPath, Status> {
let (namespace, name) = repo.split_once('/').ok_or_else(|| Status::invalid_argument("`repo` must be `<namespace>/<repo>`"))?;
Ok(pb::RepoPath { namespace: namespace.to_owned(), repo: name.to_owned() })
}
fn a_pull(repo: Option<pb::RepoPath>, number: u64, state: pb::PullRequestState) -> pb::PullRequest {
pb::PullRequest {
repo,
number,
title: "a change".to_owned(),
state: state as i32,
..Default::default()
}
}
#[tonic::async_trait]
impl crate::pb::pull_requests_server::PullRequests for FakeGunnar {
async fn create(
&self,
request: Request<pb::CreatePullRequestRequest>,
) -> Result<Response<pb::CreatePullRequestResponse>, Status> {
let who = self.admits(&request)?;
let asked = request.into_inner();
let namespace = asked.repo.as_ref().map(|r| r.namespace.clone()).unwrap_or_default();
self.write_or_refuse(&who, &namespace, "PullRequests.Create")?;
Ok(Response::new(pb::CreatePullRequestResponse {
pull: Some(a_pull(asked.repo, 1, pb::PullRequestState::Open)),
}))
}
async fn merge(
&self,
request: Request<pb::MergePullRequestRequest>,
) -> Result<Response<pb::MergePullRequestResponse>, Status> {
let who = self.admits(&request)?;
let asked = request.into_inner();
let namespace = asked.repo.as_ref().map(|r| r.namespace.clone()).unwrap_or_default();
self.write_or_refuse(&who, &namespace, "PullRequests.Merge")?;
Ok(Response::new(pb::MergePullRequestResponse {
pull: Some(a_pull(asked.repo, asked.number, pb::PullRequestState::Merged)),
plan: None,
}))
}
}
#[tonic::async_trait]
impl crate::pb::policy_server::Policy for FakeGunnar {
async fn set_protection(
&self,
request: Request<pb::SetProtectionRequest>,
) -> Result<Response<pb::SetProtectionResponse>, Status> {
let who = self.admits(&request)?;
let asked = request.into_inner();
let namespace = asked.repo.as_ref().map(|r| r.namespace.clone()).unwrap_or_default();
// gunnar gates this at Admin on the repository; the account's owner
// holds it, a stranger does not.
self.write_or_refuse(&who, &namespace, "Policy.SetProtection")?;
Ok(Response::new(pb::SetProtectionResponse { protection: asked.protection }))
}
}
/// A mutating request carrying a real session cookie for `user`.
async fn write_as(
app: &axum::Router,
keys: Option<&crate::auth::Webauthn>,
user: &str,
method: http::Method,
uri: &str,
body: serde_json::Value,
) -> (http::StatusCode, String) {
use tower::ServiceExt;
let mut request = http::Request::builder()
.method(method)
.uri(uri)
.header(http::header::ORIGIN, "https://console.example")
.header(http::header::CONTENT_TYPE, "application/json");
if let Some(keys) = keys {
let cookie = keys.make_session(user, Role::Viewer, true);
request = request.header(
http::header::COOKIE,
format!("{}={cookie}", crate::auth::SESSION_COOKIE),
);
}
let response = app
.clone()
.oneshot(
request
.body(axum::body::Body::from(body.to_string()))
.expect("a request"),
)
.await
.expect("the console answers");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 1 << 20)
.await
.expect("a readable body");
(status, String::from_utf8_lossy(&body).into_owned())
}
/// Every write a repository's owner makes on the repository's own forge pages.
fn the_owners_writes() -> Vec<(http::Method, String, serde_json::Value)> {
let base = format!("/api/repos/{TENANT}/{TENANT_REPO}");
vec![
(http::Method::POST, format!("{base}/issues"), serde_json::json!({"title": "it breaks", "body": "on Mondays"})),
(http::Method::POST, format!("{base}/pulls"), serde_json::json!({"title": "fix", "description": "", "source_ref": "refs/heads/fix", "target_ref": "refs/heads/main"})),
(http::Method::POST, format!("{base}/pulls/1/merge"), serde_json::json!({})),
(http::Method::POST, format!("{base}/protection"), serde_json::json!({"ref_pattern": "refs/heads/main"})),
]
}
/// ★ **The owner of a repository may open issues, open and merge pull requests
/// and edit branch protection on it** — as the `viewer` every customer is.
#[tokio::test]
async fn a_repositorys_owner_writes_on_it_as_a_viewer() {
let fake = FakeGunnar::new();
let (app, keys, _feed) = console(fake.clone(), true).await;
keys.plant_bound_credential_for_tests(TENANT, Role::Viewer, TENANT);
for (method, uri, body) in the_owners_writes() {
let (status, answer) = write_as(&app, Some(&keys), TENANT, method.clone(), &uri, body).await;
assert_eq!(
status,
http::StatusCode::OK,
"the owner was refused {method} {uri} on their own repository: {answer}"
);
}
// The comment on the issue just opened, too.
let (status, answer) = write_as(
&app,
Some(&keys),
TENANT,
http::Method::POST,
&format!("/api/repos/{TENANT}/{TENANT_REPO}/issues/1/comment"),
serde_json::json!({"body": "seen it"}),
)
.await;
assert_eq!(status, http::StatusCode::OK, "the owner could not comment: {answer}");
// And it was signed with the owner's key, never the console's.
let owner_key = fake.key_of_principal(TENANT).expect("the owner's key was enrolled");
assert!(fake.fingerprints().contains(&owner_key));
}
/// ★ **A stranger is refused — by gunnar, on the stranger's own key.** The
/// console's floor lets the viewer ask; the repository's grants say no.
#[tokio::test]
async fn a_stranger_cannot_write_on_someone_elses_repository() {
let fake = FakeGunnar::new();
let (app, keys, _feed) = console(fake.clone(), true).await;
keys.plant_bound_credential_for_tests(OTHER_TENANT, Role::Viewer, OTHER_TENANT);
for (method, uri, body) in the_owners_writes() {
let (status, answer) = write_as(&app, Some(&keys), OTHER_TENANT, method.clone(), &uri, body).await;
assert!(
status == http::StatusCode::FORBIDDEN || status == http::StatusCode::NOT_FOUND,
"a stranger got {status} for {method} {uri}: {answer}"
);
}
assert!(
fake.issues.lock().expect("the forge log").is_empty(),
"a stranger's issue landed in someone else's repository"
);
}
/// ★ **A visitor with no session never reaches any of it** (anonymous rules
/// unchanged).
#[tokio::test]
async fn a_visitor_cannot_write_on_any_repository() {
let fake = FakeGunnar::new();
let (app, _keys, _feed) = console(fake.clone(), true).await;
for (method, uri, body) in the_owners_writes() {
let (status, answer) = write_as(&app, None, "", method.clone(), &uri, body).await;
assert_eq!(
status,
http::StatusCode::UNAUTHORIZED,
"a visitor got {status} for {method} {uri}: {answer}"
);
}
}
/// ★ **What the pages draw from**: gunnar's per-repository answer for THIS
/// person — the owner may write and administer, a stranger may do neither,
/// and a visitor is not answered at all.
#[tokio::test]
async fn the_rights_read_is_gunnars_answer_for_this_person() {
let fake = FakeGunnar::new();
let (app, keys, _feed) = console(fake.clone(), true).await;
keys.plant_bound_credential_for_tests(TENANT, Role::Viewer, TENANT);
keys.plant_bound_credential_for_tests(OTHER_TENANT, Role::Viewer, OTHER_TENANT);
let uri = format!("/api/repos/{TENANT}/{TENANT_REPO}/rights");
let (status, body) = signed_in(&app, &keys, TENANT, Role::Viewer, &uri).await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let owner: serde_json::Value = serde_json::from_str(&body).expect("json");
assert_eq!(owner["write"], true, "{body}");
assert_eq!(owner["admin"], true, "{body}");
let (status, body) = signed_in(&app, &keys, OTHER_TENANT, Role::Viewer, &uri).await;
assert_eq!(status, http::StatusCode::OK, "{body}");
let stranger: serde_json::Value = serde_json::from_str(&body).expect("json");
assert_eq!(stranger["write"], false, "{body}");
assert_eq!(stranger["admin"], false, "{body}");
let (status, _) = anon(&app, &uri).await;
assert_eq!(status, http::StatusCode::UNAUTHORIZED);
}