crates/monetize-ui-server/src/role.rs
//! Who a console session is allowed to be. The BFF's own ladder — monetize sees
//! exactly one principal, the bearer this process holds; this is about which
//! human is driving, enforced here before a call is ever made.
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.
Viewer,
/// Read, and nothing on this console mutates below admin today.
Operator,
/// Everything: plans, the operator `Entitlement.Set`, passkeys.
Admin,
}
impl Role {
pub fn allows(self, needed: Role) -> bool {
self >= needed
}
/// The token in a session payload. `|`-delimited, so never contains `|`.
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())
}
}
impl FromStr for Role {
type Err = String;
fn from_str(s: &str) -> Result<Role, String> {
match s.trim().to_ascii_lowercase().as_str() {
"viewer" => Ok(Role::Viewer),
"operator" => Ok(Role::Operator),
"admin" => Ok(Role::Admin),
other => Err(format!("{other:?} is not viewer, operator or admin")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_ladder_climbs_one_way_and_tokens_round_trip() {
assert!(Role::Admin.allows(Role::Viewer));
assert!(!Role::Viewer.allows(Role::Operator));
assert!(!Role::Operator.allows(Role::Admin));
for role in [Role::Viewer, Role::Operator, Role::Admin] {
assert_eq!(role.as_token().parse::<Role>().unwrap(), role);
}
// Defaulting here would silently promote a corrupted cookie.
assert!("root".parse::<Role>().is_err());
}
}