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/members.rs

//! **The account admin gives a member Read or Write on one of the account's
//! repositories, and takes it away** (owner 2026-10-07, the members model).
//!
//! A member (a user the account's admin enrolled from `/<account>` → Add
//! users) reads every repository of the account — enrolment gives him `Read`
//! on its namespace — and pushes where its admin gave him `Write`. Until this
//! module the console had no way to give it: the walkthrough's admin had to
//! ask the box's operator for `gunnar control grant`.
//!
//! # Thin, by construction
//!
//! Every call is signed AS THE PERSON ([`crate::api::Actor`]) and gunnar
//! decides:
//!
//! * the list is `Accounts.ListEmployees` (the account admin's) and
//!   `Access.ListGrants` naming the account's namespace (a server admin's, or
//!   that namespace's admin's — gunnar's rule since 2026-10-07);
//! * a grant is `Access.Grant` on the repository (Admin over the repository,
//!   which the account's admin holds), and taking it away `Access.Revoke`.
//!
//! A member who sends the same PUT is refused by gunnar, in gunnar's words,
//! and gunnar changes nothing. The console joins the two lists into one table
//! and decides nothing.

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

use crate::api::{Actor, ApiError, ApiResult};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;

/// One member of the account, and what gunnar says they hold here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Member {
    pub id: String,
    pub name: String,
    /// The grant ON THIS REPOSITORY (`read` / `write` / `admin`), if any.
    pub repo_level: Option<&'static str>,
    /// The grant on the whole account (enrolment gives `read`), if any.
    pub account_level: Option<&'static str>,
}

/// `GET /api/repos/{namespace}/{name}/members`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Members {
    pub account: String,
    pub repo: String,
    pub members: Vec<Member>,
}

fn level_word(level: i32) -> Option<&'static str> {
    match pb::GrantLevel::try_from(level) {
        Ok(pb::GrantLevel::Read) => Some("read"),
        Ok(pb::GrantLevel::Write) => Some("write"),
        Ok(pb::GrantLevel::Admin) => Some("admin"),
        _ => None,
    }
}

/// **The table**: the account's members, each with gunnar's grants on this
/// repository and on the account. Pure, so a test can state it.
pub fn members_of(account: &str, repo: &str, employees: &[pb::Principal], grants: &[pb::Grant]) -> Members {
    let held = |id: &str, on_repo: &str| {
        grants
            .iter()
            .filter(|g| g.principal_id == id && g.namespace == account && g.repo == on_repo)
            .map(|g| g.level)
            .max()
            .and_then(level_word)
    };
    let mut members: Vec<Member> = employees
        .iter()
        .map(|p| Member {
            id: p.id.clone(),
            name: if p.name.is_empty() { p.id.clone() } else { p.name.clone() },
            repo_level: held(&p.id, repo),
            account_level: held(&p.id, ""),
        })
        .collect();
    members.sort_by(|a, b| a.id.cmp(&b.id));
    Members { account: account.to_owned(), repo: repo.to_owned(), members }
}

/// `GET /api/repos/{namespace}/{name}/members` — asked as the person; gunnar
/// answers its account admin and refuses anybody else.
pub async fn list(actor: Actor, Path((namespace, name)): Path<(String, String)>) -> ApiResult<Members> {
    let employees = actor
        .gunnar
        .accounts()
        .list_employees(pb::ListEmployeesRequest { account: namespace.clone() })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .employees;
    let grants = actor
        .gunnar
        .access()
        .list_grants(pb::ListGrantsRequest { principal_id: String::new(), namespace: namespace.clone(), repo: String::new() })
        .await
        .map_err(ApiError::from)?
        .into_inner()
        .grants;
    Ok(Json(members_of(&namespace, &name, &employees, &grants)))
}

/// What the page sends: `read` or `write`.
#[derive(Debug, Clone, Deserialize)]
pub struct Give {
    pub level: String,
}

/// `PUT /api/repos/{namespace}/{name}/members/{principal}` — `Access.Grant`
/// on the repository, signed as the person.
pub async fn give(
    actor: Actor,
    State(state): State<AppState>,
    Path((namespace, name, principal)): Path<(String, String, String)>,
    Json(give): Json<Give>,
) -> ApiResult<Member> {
    let level = match give.level.trim() {
        "read" => pb::GrantLevel::Read,
        "write" => pb::GrantLevel::Write,
        other => {
            return Err(ApiError(Fail {
                code: "invalidargument".to_owned(),
                message: format!("{other:?} is not read or write: a member is given Read or Write on a repository"),
            }))
        }
    };
    actor
        .gunnar
        .access()
        .grant(pb::GrantRequest { principal_id: principal.clone(), namespace: namespace.clone(), repo: name.clone(), level: level as i32 })
        .await
        .map_err(ApiError::from)?;
    state.caches.invalidate_access().await;
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "account.member_granted",
        user = %actor.who,
        repo = %format!("{namespace}/{name}"),
        member = %principal,
        level = %give.level.trim(),
        "the account admin gave a member access to a repository"
    );
    Ok(Json(Member { id: principal.clone(), name: principal, repo_level: level_word(level as i32), account_level: None }))
}

/// `DELETE /api/repos/{namespace}/{name}/members/{principal}` —
/// `Access.Revoke` on the repository, signed as the person. What the account
/// gives every member (its `Read`) stays.
pub async fn take(
    actor: Actor,
    State(state): State<AppState>,
    Path((namespace, name, principal)): Path<(String, String, String)>,
) -> ApiResult<super::repos::Done> {
    actor
        .gunnar
        .access()
        .revoke(pb::RevokeRequest { principal_id: principal.clone(), namespace: namespace.clone(), repo: name.clone() })
        .await
        .map_err(ApiError::from)?;
    state.caches.invalidate_access().await;
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "account.member_revoked",
        user = %actor.who,
        repo = %format!("{namespace}/{name}"),
        member = %principal,
        "the account admin took a member's access to a repository away"
    );
    Ok(Json(super::repos::Done { ok: true }))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn grant(who: &str, ns: &str, repo: &str, level: pb::GrantLevel) -> pb::Grant {
        pb::Grant { principal_id: who.into(), namespace: ns.into(), repo: repo.into(), level: level as i32, ..Default::default() }
    }

    /// ★ **One row per member, with gunnar's grants on this repository and on
    /// the account** — another repository's grant and another account's are
    /// not this one's.
    #[test]
    fn the_table_is_each_member_with_what_gunnar_says_they_hold_here() {
        let employees = [
            pb::Principal { id: "ben".into(), name: "Ben".into(), ..Default::default() },
            pb::Principal { id: "ann".into(), name: String::new(), ..Default::default() },
        ];
        let grants = [
            grant("ben", "acme", "", pb::GrantLevel::Read),
            grant("ben", "acme", "handbook", pb::GrantLevel::Write),
            grant("ann", "acme", "", pb::GrantLevel::Read),
            grant("ann", "acme", "site", pb::GrantLevel::Write),
            grant("ann", "other", "handbook", pb::GrantLevel::Admin),
        ];
        let table = members_of("acme", "handbook", &employees, &grants);
        assert_eq!(table.members.len(), 2);
        assert_eq!(table.members[0], Member { id: "ann".into(), name: "ann".into(), repo_level: None, account_level: Some("read") });
        assert_eq!(table.members[1], Member { id: "ben".into(), name: "Ben".into(), repo_level: Some("write"), account_level: Some("read") });
    }
}