use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PrincipalKind {
User,
ApiKey,
Service,
}
impl Default for PrincipalKind {
fn default() -> Self {
PrincipalKind::User
}
}
impl PrincipalKind {
pub fn as_str(self) -> &'static str {
match self {
PrincipalKind::User => "user",
PrincipalKind::ApiKey => "apikey",
PrincipalKind::Service => "service",
}
}
pub fn parse(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"apikey" | "api_key" | "api-key" => PrincipalKind::ApiKey,
"service" => PrincipalKind::Service,
_ => PrincipalKind::User,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserContext {
pub subject: String,
#[serde(default)]
pub principal: PrincipalKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_id: Option<String>,
}
impl UserContext {
pub fn user_id(&self) -> Result<Uuid, AppError> {
Uuid::parse_str(&self.subject).map_err(|_| AppError::Unauthorized {
message: "subject is not a valid user id".to_string(),
})
}
pub fn user_id_opt(&self) -> Option<Uuid> {
Uuid::parse_str(&self.subject).ok()
}
pub fn is_user(&self) -> bool {
self.principal == PrincipalKind::User
}
pub fn is_api_key(&self) -> bool {
self.principal == PrincipalKind::ApiKey
}
pub fn is_service(&self) -> bool {
self.principal == PrincipalKind::Service
}
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
pub fn has_any_role(&self, roles: &[&str]) -> bool {
roles.iter().any(|role| self.has_role(role))
}
pub fn require_role(&self, role: &str) -> Result<(), AppError> {
if self.has_role(role) {
Ok(())
} else {
Err(AppError::Forbidden {
message: format!("Missing role: {role}"),
})
}
}
pub fn require_any_role(&self, roles: &[&str]) -> Result<(), AppError> {
if self.has_any_role(roles) {
Ok(())
} else {
Err(AppError::Forbidden {
message: format!("Missing any of roles: {}", roles.join(", ")),
})
}
}
pub fn is_admin(&self) -> bool {
self.roles.iter().any(|role| {
let upper = role.to_ascii_uppercase();
upper == "ADMIN" || upper.ends_with("_ADMIN")
})
}
pub fn require_admin(&self) -> Result<(), AppError> {
if self.is_admin() {
Ok(())
} else {
Err(AppError::Forbidden {
message: "Admin role required".to_string(),
})
}
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope)
}
pub fn require_scope(&self, scope: &str) -> Result<(), AppError> {
if self.has_scope(scope) {
Ok(())
} else {
Err(AppError::Forbidden {
message: format!("Missing scope: {scope}"),
})
}
}
}
#[cfg(feature = "web")]
mod web_impl {
use super::*;
use actix_web::{dev::Payload, FromRequest, HttpMessage, HttpRequest};
use futures_util::future::{ready, Ready};
impl FromRequest for UserContext {
type Error = AppError;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
match req.extensions().get::<UserContext>().cloned() {
Some(ctx) => ready(Ok(ctx)),
None => ready(Err(AppError::Unauthorized {
message: "Missing user context".to_string(),
})),
}
}
}
}