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