Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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