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