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