crates/gunnar-ui-server/src/api/access.rs
//! Principals, keys and grants — gunnar's identity table.
//!
//! Identity in gunnar comes from the key, never from the SSH username, and
//! "gunnar-admin" is not a role: it is a server-scoped admin grant. The console
//! shows the table as it actually is rather than inventing roles on top of it.
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api::{ApiError, ApiResult, Limit, Page, Reader};
use crate::app::AppState;
use crate::pb;
/// Someone or something that can be granted access.
#[derive(Debug, Clone, Serialize)]
pub struct Principal {
pub id: String,
pub name: String,
pub kind: &'static str,
/// `None` when gunnar did not record one.
pub created_at_unix_ms: Option<i64>,
/// When gunnar last issued this principal a console session (a passkey or
/// SSO login). `None`: never. gunnar's answer, passed through (2026-10-21).
pub last_sign_in_unix_ms: Option<i64>,
/// When this principal last made a call that changed something (a push, a
/// repository made or removed, a setting, a key…). `None`: never.
pub last_operation_unix_ms: Option<i64>,
}
/// A registered SSH key.
#[derive(Debug, Clone, Serialize)]
pub struct Key {
/// `SHA256:…`, and the identity gunnar actually authenticates.
pub fingerprint: String,
pub algo: String,
pub principal_id: String,
pub comment: String,
/// `None` when gunnar did not record one.
pub added_at_unix_ms: Option<i64>,
/// Always `None` on every gunnar: the code that would record it is
/// deliberately never called. The console renders the difference between
/// "never used" and "not recorded" rather than showing an empty cell.
pub last_used_unix_ms: Option<i64>,
pub expires_at_unix_ms: Option<i64>,
}
/// One row of the access table.
#[derive(Debug, Clone, Serialize)]
pub struct Grant {
pub principal_id: String,
/// Empty for a server-wide grant.
pub namespace: String,
/// Empty for a namespace-wide grant.
pub repo: String,
pub level: &'static str,
/// What the grant covers: `server`, `namespace` or `repo`.
pub scope: &'static str,
/// `None` when gunnar did not record one.
pub granted_at_unix_ms: Option<i64>,
}
/// `GET /api/principals`
///
/// **Asked as the reader** (thin layer, owner 2026-10-04). gunnar answers
/// `ListPrincipals` to a server-wide admin only — "a principal list and a key
/// list are the reconnaissance half of the same attack" — so a customer's,
/// an importer's or an employee's session is refused BY GUNNAR, on their own
/// key. Until this line the console asked with its own server-wide admin key
/// for every session at the viewer floor and handed the whole identity table
/// to any of them. Every page is walked (`next_page_token`), not just the
/// first.
pub async fn list_principals(
reader: Reader,
State(state): State<AppState>,
Query(limit): Query<Limit>,
) -> ApiResult<Page<Principal>> {
let all = state
.caches
.principals
.get_for(reader.may_cache(), async {
let mut all = Vec::new();
let mut page_token = String::new();
loop {
let response = reader
.gunnar
.access()
.list_principals(pb::ListPrincipalsRequest {
page_token: page_token.clone(),
..Default::default()
})
.await
.map_err(ApiError::from)?
.into_inner();
all.extend(render_principals(response.principals, &response.last_sign_in_unix_ms, &response.last_operation_unix_ms));
// A gunnar that pages hands a token back; one that does not
// (or an empty last page) ends the walk. A token that repeats
// would loop forever, so it ends it too.
if response.next_page_token.is_empty() || response.next_page_token == page_token {
break;
}
page_token = response.next_page_token;
}
Ok::<_, ApiError>(all)
})
.await?;
Ok(Json(Page::of(all, limit.get())))
}
/// `GET /api/keys` — every key on the box. Asked as the reader: gunnar
/// answers a server-wide admin only (see [`list_principals`]).
pub async fn list_keys(
reader: Reader,
State(state): State<AppState>,
Query(limit): Query<Limit>,
) -> ApiResult<Page<Key>> {
let all = state
.caches
.keys
.get_for(reader.may_cache(), async {
let response = reader
.gunnar
.access()
// An empty principal id means every key.
.list_keys(pb::ListKeysRequest::default())
.await
.map_err(ApiError::from)?
.into_inner();
Ok::<_, ApiError>(
response
.keys
.into_iter()
.map(render_key)
.collect::<Vec<_>>(),
)
})
.await?;
Ok(Json(Page::of(all, limit.get())))
}
/// `GET /api/grants` — the whole grant table. Asked as the reader: gunnar
/// answers a server-wide admin only (see [`list_principals`]).
pub async fn list_grants(
reader: Reader,
State(state): State<AppState>,
Query(limit): Query<Limit>,
) -> ApiResult<Page<Grant>> {
let all = state
.caches
.grants
.get_for(reader.may_cache(), async {
let response = reader
.gunnar
.access()
.list_grants(pb::ListGrantsRequest::default())
.await
.map_err(ApiError::from)?
.into_inner();
Ok::<_, ApiError>(
response
.grants
.into_iter()
.map(render_grant)
.collect::<Vec<_>>(),
)
})
.await?;
Ok(Json(Page::of(all, limit.get())))
}
/// What the console sends to add a principal.
#[derive(Debug, Clone, Deserialize)]
pub struct NewPrincipal {
pub name: String,
/// `user`, `machine` or `deploy`.
pub kind: Option<String>,
}
/// `POST /api/principals`
///
/// Note that gunnar creates an account namespace named after the principal as a
/// side effect, and refuses the whole call if that namespace already exists.
pub async fn add_principal(
actor: crate::api::Actor,
State(state): State<AppState>,
Json(new): Json<NewPrincipal>,
) -> ApiResult<Principal> {
let response = actor
.gunnar
.access()
.add_principal(pb::AddPrincipalRequest {
name: new.name,
kind: principal_kind_from_name(new.kind.as_deref()) as i32,
// **Empty means "the same as `name`"**, which is what every caller
// got before gunnar split the id from the display name. A console
// that started choosing account slugs here would be choosing them
// for everybody who ever used this form, so the field is carried
// and left to gunnar's own default.
id: String::new(),
})
.await
.map_err(ApiError::from)?
.into_inner();
state.caches.invalidate_access().await;
Ok(Json(render_principal(
response.principal.unwrap_or_default(),
)))
}
/// `DELETE /api/principals/{id}`
pub async fn remove_principal(
actor: crate::api::Actor,
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Removed> {
let response = actor
.gunnar
.access()
.remove_principal(pb::RemovePrincipalRequest { principal_id: id })
.await
.map_err(ApiError::from)?
.into_inner();
state.caches.invalidate_access().await;
Ok(Json(Removed {
keys_revoked: response.keys_revoked,
grants_revoked: response.grants_revoked,
}))
}
/// What a removal took with it.
#[derive(Debug, Clone, Serialize)]
pub struct Removed {
pub keys_revoked: u32,
pub grants_revoked: u32,
}
/// What the console sends to register a key.
#[derive(Debug, Clone, Deserialize)]
pub struct NewKey {
pub principal_id: String,
/// A full OpenSSH public key line.
pub openssh_public_key: String,
#[serde(default)]
pub comment: String,
pub expires_at_unix_ms: Option<i64>,
}
/// `POST /api/keys`
pub async fn add_key(
actor: crate::api::Actor,State(state): State<AppState>, Json(new): Json<NewKey>) -> ApiResult<Key> {
let response = actor
.gunnar
.access()
.add_key(pb::AddKeyRequest {
principal_id: new.principal_id,
openssh_public_key: new.openssh_public_key,
comment: new.comment,
expires_at_unix_ms: new.expires_at_unix_ms,
// **The target principal's own word that this key may act as
// them**, and the console has no way to obtain one: the attesting
// signature must be made with a key the TARGET holds, which lives
// in their browser or their agent and never here. gunnar requires
// it exactly when the target already holds a signing key and the
// caller is not the target — first enrolment is unaffected — and
// refuses with `FAILED_PRECONDITION` otherwise. That refusal is
// propagated in gunnar's own words rather than pre-empted here,
// because this console cannot know which of the two cases a
// principal is in without asking.
attestation: None,
})
.await
.map_err(ApiError::from)?
.into_inner();
state.caches.invalidate_access().await;
Ok(Json(render_key(response.key.unwrap_or_default())))
}
/// `DELETE /api/keys/{fingerprint}`
pub async fn revoke_key(
actor: crate::api::Actor,
State(state): State<AppState>,
Path(fingerprint): Path<String>,
) -> ApiResult<super::repos::Done> {
actor
.gunnar
.access()
.revoke_key(pb::RevokeKeyRequest { fingerprint })
.await
.map_err(ApiError::from)?;
state.caches.invalidate_access().await;
Ok(Json(super::repos::Done { ok: true }))
}
/// What the console sends to grant access.
#[derive(Debug, Clone, Deserialize)]
pub struct NewGrant {
pub principal_id: String,
/// Empty grants server-wide.
#[serde(default)]
pub namespace: String,
/// Empty grants across the whole namespace.
#[serde(default)]
pub repo: String,
/// `read`, `write` or `admin`.
pub level: String,
}
/// `POST /api/grants`
pub async fn grant(
actor: crate::api::Actor,State(state): State<AppState>, Json(new): Json<NewGrant>) -> ApiResult<Grant> {
let level = grant_level_from_name(&new.level).ok_or_else(|| {
ApiError(crate::grpc::Fail {
code: "invalidargument".to_owned(),
message: format!("{:?} is not read, write or admin", new.level),
})
})?;
let response = actor
.gunnar
.access()
.grant(pb::GrantRequest {
principal_id: new.principal_id,
namespace: new.namespace,
repo: new.repo,
level: level as i32,
})
.await
.map_err(ApiError::from)?
.into_inner();
state.caches.invalidate_access().await;
Ok(Json(render_grant(response.grant.unwrap_or_default())))
}
/// What the console sends to take access away.
#[derive(Debug, Clone, Deserialize)]
pub struct DropGrant {
pub principal_id: String,
#[serde(default)]
pub namespace: String,
#[serde(default)]
pub repo: String,
}
/// `POST /api/grants/revoke`
///
/// A POST rather than a DELETE because the grant is named by three fields, two
/// of which are meaningfully empty, and a path cannot say "empty" clearly.
pub async fn revoke_grant(
actor: crate::api::Actor,
State(state): State<AppState>,
Json(drop): Json<DropGrant>,
) -> ApiResult<super::repos::Done> {
actor
.gunnar
.access()
.revoke(pb::RevokeRequest {
principal_id: drop.principal_id,
namespace: drop.namespace,
repo: drop.repo,
})
.await
.map_err(ApiError::from)?;
state.caches.invalidate_access().await;
Ok(Json(super::repos::Done { ok: true }))
}
/// gunnar's principal kind as the console's word.
pub(crate) fn principal_kind_name(kind: i32) -> &'static str {
match pb::PrincipalKind::try_from(kind) {
Ok(pb::PrincipalKind::User) => "user",
Ok(pb::PrincipalKind::Machine) => "machine",
Ok(pb::PrincipalKind::Deploy) => "deploy",
Ok(pb::PrincipalKind::Group) => "group",
_ => "unspecified",
}
}
fn render_principal(principal: pb::Principal) -> Principal {
Principal {
id: principal.id,
name: principal.name,
kind: principal_kind_name(principal.kind),
created_at_unix_ms: crate::api::recorded(principal.created_at_unix_ms),
last_sign_in_unix_ms: None,
last_operation_unix_ms: None,
}
}
/// One page of `Access.ListPrincipals`, each principal with its last sign-in
/// and last operation from the answer's two maps (no entry: never).
fn render_principals(
principals: Vec<pb::Principal>,
last_sign_in: &std::collections::HashMap<String, i64>,
last_operation: &std::collections::HashMap<String, i64>,
) -> Vec<Principal> {
principals
.into_iter()
.map(|p| {
let signed_in = last_sign_in.get(&p.id).copied();
let operated = last_operation.get(&p.id).copied();
Principal {
last_sign_in_unix_ms: signed_in,
last_operation_unix_ms: operated,
..render_principal(p)
}
})
.collect()
}
/// `pub(crate)` so `api::account` renders a key the same way this page does.
/// Two spellings of one row is how one of them goes stale.
pub(crate) fn render_key(key: pb::PublicKey) -> Key {
Key {
fingerprint: key.fingerprint,
algo: key.algo,
principal_id: key.principal_id,
comment: key.comment,
added_at_unix_ms: crate::api::recorded(key.added_at_unix_ms),
last_used_unix_ms: key.last_used_unix_ms,
expires_at_unix_ms: key.expires_at_unix_ms,
}
}
fn render_grant(grant: pb::Grant) -> Grant {
Grant {
scope: match (grant.namespace.is_empty(), grant.repo.is_empty()) {
(true, _) => "server",
(false, true) => "namespace",
(false, false) => "repo",
},
level: match pb::GrantLevel::try_from(grant.level) {
Ok(pb::GrantLevel::Read) => "read",
Ok(pb::GrantLevel::Write) => "write",
Ok(pb::GrantLevel::Admin) => "admin",
_ => "unspecified",
},
principal_id: grant.principal_id,
namespace: grant.namespace,
repo: grant.repo,
granted_at_unix_ms: crate::api::recorded(grant.granted_at_unix_ms),
}
}
fn principal_kind_from_name(name: Option<&str>) -> pb::PrincipalKind {
match name {
Some("machine") => pb::PrincipalKind::Machine,
Some("deploy") => pb::PrincipalKind::Deploy,
Some("user") => pb::PrincipalKind::User,
_ => pb::PrincipalKind::Unspecified,
}
}
fn grant_level_from_name(name: &str) -> Option<pb::GrantLevel> {
match name {
"read" => Some(pb::GrantLevel::Read),
"write" => Some(pb::GrantLevel::Write),
"admin" => Some(pb::GrantLevel::Admin),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// gunnar's two maps land on the right rows; a principal with no entry
/// is `null` ("never"), not a zero.
#[test]
fn each_principal_carries_its_last_sign_in_and_last_operation() {
let p = |id: &str| pb::Principal { id: id.into(), name: id.into(), kind: pb::PrincipalKind::User as i32, created_at_unix_ms: 5 };
let signed_in = std::collections::HashMap::from([("kim".to_owned(), 1_000)]);
let operated = std::collections::HashMap::from([("kim".to_owned(), 2_000), ("boss".to_owned(), 3_000)]);
let rows = render_principals(vec![p("kim"), p("boss"), p("eve")], &signed_in, &operated);
let json = serde_json::to_value(&rows).expect("json");
assert_eq!(json[0]["last_sign_in_unix_ms"], 1_000);
assert_eq!(json[0]["last_operation_unix_ms"], 2_000);
assert_eq!(json[1]["last_sign_in_unix_ms"], serde_json::Value::Null);
assert_eq!(json[1]["last_operation_unix_ms"], 3_000);
assert_eq!(json[2]["last_sign_in_unix_ms"], serde_json::Value::Null);
assert_eq!(json[2]["last_operation_unix_ms"], serde_json::Value::Null);
}
#[test]
fn a_grants_scope_comes_from_which_fields_are_empty() {
let server = render_grant(pb::Grant::default());
let namespace = render_grant(pb::Grant {
namespace: "vetra".into(),
..Default::default()
});
let repo = render_grant(pb::Grant {
namespace: "vetra".into(),
repo: "gunnar".into(),
..Default::default()
});
assert_eq!(server.scope, "server");
assert_eq!(namespace.scope, "namespace");
assert_eq!(repo.scope, "repo");
}
#[test]
fn a_key_that_was_never_recorded_as_used_stays_absent() {
// gunnar never writes this field, so the console must not render a
// date, and must not render an empty cell either.
let key = render_key(pb::PublicKey {
fingerprint: "SHA256:abc".into(),
last_used_unix_ms: None,
..Default::default()
});
assert_eq!(key.last_used_unix_ms, None);
}
#[test]
fn grant_levels_parse_only_the_three_that_exist() {
assert_eq!(grant_level_from_name("read"), Some(pb::GrantLevel::Read));
assert_eq!(grant_level_from_name("owner"), None);
assert_eq!(grant_level_from_name(""), None);
}
#[test]
fn an_unnamed_principal_kind_is_left_unspecified_for_gunnar_to_default() {
assert_eq!(
principal_kind_from_name(None),
pb::PrincipalKind::Unspecified
);
assert_eq!(
principal_kind_from_name(Some("machine")),
pb::PrincipalKind::Machine
);
}
}