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/api/visibility.rs

//! **Who may change a repository's visibility** — asked of gunnar.
//!
//! gunnar's default for EVERY account (the owner, 2026-10-03: "github
//! enterprise makes it impossible for non admin to make repos public"; there is
//! no Enterprise tier and no flag — it is simply how gunnar works): only the
//! ACCOUNT admin may move a repository between private and public — not whoever
//! holds `admin` on the one repository. gunnar enforces it on
//! `Repos.SetVisibility` (`Admin` over the NAMESPACE, the account gate
//! honoured).
//!
//! Since 2026-10-04 (thin layer) the console asks nothing of its own: the PATCH
//! is signed AS THE PERSON ([`crate::api::Actor`]) and gunnar decides it, and
//! the page's hint below is gunnar's `Access.Check{namespace, ADMIN}` asked as
//! the same person — the exact rule the verb applies, said without acting.
//! Until then this module read gunnar's whole grant table with the console's
//! own key and re-implemented the rule (skipping the gate).
//!
//! Since 2026-10-06 `role_floor` puts that PATCH at viewer: a paying customer
//! is a viewer and the admin of their own account, and gunnar's rule is the
//! one that decides. `may_change` is gunnar's answer for any signed-in person.

use axum::extract::{Path, State};
use axum::Json;
use serde::Serialize;

use crate::api::{Actor, ApiError};
use crate::app::AppState;
use crate::auth::middleware::Caller;
use crate::pb;
use crate::role::Role;

/// What `GET /api/repos/{namespace}/{name}/visibility` answers: whether this
/// session may change the repository's visibility. The page draws the control
/// from this and nothing else.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Authority {
    /// The account the repository belongs to — its namespace.
    pub account: String,
    /// gunnar says this session's person is the account's admin.
    pub account_admin: bool,
    /// The PATCH this session would send can succeed: account admin AND a
    /// role `role_floor` lets mutate. The control is drawn on this.
    pub may_change: bool,
    /// Why not, in the words the panel prints, when `may_change` is false.
    pub why: Option<String>,
}

/// The sentence for a session that is not the account's admin.
pub fn not_account_admin_words(account: &str) -> String {
    format!(
        "Only the account admin of {account} may change what a repository in it is readable \
         by; an admin grant on the repository alone does not reach it."
    )
}

/// **The whole answer**, for the page: gunnar's account-admin rule, asked as
/// this session's person, and the role floor the PATCH meets first.
pub async fn authority(
    state: &AppState,
    caller: Option<&Caller>,
    account: &str,
) -> Result<Authority, ApiError> {
    // The PATCH's floor is a viewer's (`role_floor`): any signed-in person may
    // send it, and gunnar decides; with no session, only the open console.
    let may_mutate = match caller {
        Some(caller) => caller.role().allows(Role::Viewer),
        None => !state.passkey_enabled() && state.admin_token.is_none(),
    };
    let (account_admin, refusal) = match Actor::of(state, caller).await {
        Ok(actor) => {
            let (allowed, reason) = crate::api::may(&actor.gunnar, account, "", pb::GrantLevel::Admin).await?;
            (allowed, (!allowed).then_some(reason))
        }
        // No person to ask as: nobody signed in, or a sign-in bound to no
        // account here. The refusal says which.
        Err(why) => (false, Some(why.0.message)),
    };
    let why = if !account_admin {
        Some(format!(
            "{} {}",
            not_account_admin_words(account),
            refusal.unwrap_or_default()
        )
        .trim_end()
        .to_owned())
    } else if !may_mutate {
        Some("Sign in to change what this repository is readable by.".to_owned())
    } else {
        None
    };
    Ok(Authority {
        account: account.to_owned(),
        account_admin,
        may_change: account_admin && may_mutate,
        why,
    })
}

/// `GET /api/repos/{namespace}/{name}/visibility` — may this session change the
/// repository's visibility?
///
/// A GET, so `role_floor` puts it at viewer; it is not on the anonymous
/// allow-list, so a visitor never reaches it. gunnar answers it, about THIS
/// session's person only.
pub async fn read(
    State(state): State<AppState>,
    caller: Option<axum::Extension<Caller>>,
    Path((namespace, _name)): Path<(String, String)>,
) -> Result<Json<Authority>, ApiError> {
    Ok(Json(
        authority(&state, caller.as_ref().map(|c| &c.0), &namespace).await?,
    ))
}