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

//! Who a console session is allowed to be — **gunnar's answer, not the
//! console's** (thin layer, owner 2026-10-04: "roles → gunnar grants").
//!
//! A console role is a reading of the person's own SERVER-WIDE grant at
//! gunnar, asked with their own key (`Access.WhoAmI.server_level`):
//!
//! * `admin` — a server-wide `Admin` grant;
//! * `operator` — a server-wide `Write` grant;
//! * `viewer` — anything less (a person reading their own account, an
//!   employee, a paying customer).
//!
//! So `gunnar control grant list` shows who is effectively an administrator of
//! the console, gunnar's audit names the person for every act (each one is
//! signed with their own key, [`crate::api::Reader`] / [`crate::api::Actor`]),
//! and promoting or demoting somebody is an `Access.Grant` — never a row in
//! this console's store. The ladder below survives as the floor
//! `auth::middleware::role_floor` checks before a request reaches a handler; it
//! is a reading of gunnar's grants, refreshed on a short clock
//! (`auth::Webauthn::gunnar_role`), and gunnar decides every call again.
//!
//! The break-glass token (bootstrap, before any passkey exists) and the open
//! console (loopback development) stay `admin` by how the process was started.

use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};

/// A console role. Ordered: each level allows everything below it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    /// Read the console. Cannot change anything.
    Viewer,
    /// Everything a viewer can do, plus repository and reference work.
    Operator,
    /// Everything, including identity, grants and passkey management.
    Admin,
}

impl Role {
    /// **The console role a server-wide grant level stands for**: `Admin` →
    /// admin, `Write` → operator, anything less → viewer.
    pub fn from_server_level(level: i32) -> Role {
        match crate::pb::GrantLevel::try_from(level) {
            Ok(crate::pb::GrantLevel::Admin) => Role::Admin,
            Ok(crate::pb::GrantLevel::Write) => Role::Operator,
            _ => Role::Viewer,
        }
    }

    /// The server-wide grant level this console role stands for (the inverse
    /// of [`Role::from_server_level`]); `None` for a viewer, who needs none.
    pub fn server_level(self) -> Option<crate::pb::GrantLevel> {
        match self {
            Role::Admin => Some(crate::pb::GrantLevel::Admin),
            Role::Operator => Some(crate::pb::GrantLevel::Write),
            Role::Viewer => None,
        }
    }

    /// True when this role satisfies a requirement for `needed`.
    pub fn allows(self, needed: Role) -> bool {
        self >= needed
    }

    /// The lowercase token used in a session payload.
    ///
    /// The payload is `|`-delimited, so this must never contain a `|`.
    pub fn as_token(self) -> &'static str {
        match self {
            Role::Viewer => "viewer",
            Role::Operator => "operator",
            Role::Admin => "admin",
        }
    }
}

impl fmt::Display for Role {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_token())
    }
}

/// The role token was not one of the three.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownRole(pub String);

impl fmt::Display for UnknownRole {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:?} is not viewer, operator or admin", self.0)
    }
}

impl std::error::Error for UnknownRole {}

impl FromStr for Role {
    type Err = UnknownRole;

    fn from_str(s: &str) -> Result<Role, UnknownRole> {
        match s.trim().to_ascii_lowercase().as_str() {
            "viewer" => Ok(Role::Viewer),
            "operator" => Ok(Role::Operator),
            "admin" => Ok(Role::Admin),
            other => Err(UnknownRole(other.to_owned())),
        }
    }
}

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

    #[test]
    fn the_ladder_climbs_one_way() {
        assert!(Role::Admin.allows(Role::Viewer));
        assert!(Role::Operator.allows(Role::Viewer));
        assert!(!Role::Viewer.allows(Role::Operator));
        assert!(!Role::Operator.allows(Role::Admin));
    }

    #[test]
    fn tokens_round_trip() {
        for role in [Role::Viewer, Role::Operator, Role::Admin] {
            assert_eq!(role.as_token().parse::<Role>().expect("own token"), role);
            assert!(
                !role.as_token().contains('|'),
                "would break the session payload"
            );
        }
    }

    #[test]
    fn an_unknown_token_is_refused_rather_than_defaulted() {
        // Defaulting here would silently promote a corrupted cookie.
        assert!("root".parse::<Role>().is_err());
        assert!("".parse::<Role>().is_err());
    }
}