road-runner-common 0.4.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::error::AppError;

/// What kind of caller this context represents.
///
/// All principals carry a `user_id`:
/// - `User`    → the authenticated end user.
/// - `ApiKey`  → a machine key acting on behalf of its owning user (`user_id` = owner,
///               `api_key_id` = the key).
/// - `Service` → an internal service-to-service caller.
#[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,
        }
    }
}

/// The authenticated caller, resolved once at the edge (gateway) and propagated to every
/// service via signed headers. This is the single identity type shared across all services.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserContext {
    /// Canonical, opaque principal id. For `User` this is the user's UUID (as a string);
    /// for `ApiKey`/`Service` it is the principal's stable id. Use [`UserContext::user_id`]
    /// when a typed UUID is required.
    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>,

    /// Active organization / tenant, when the caller is operating in one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant_id: Option<String>,

    /// Present only for `ApiKey` principals.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key_id: Option<String>,
}

impl UserContext {
    /// Parse the subject as a user UUID. Errors for non-UUID principals (e.g. API keys).
    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(),
        })
    }

    /// Parse the subject as a user UUID, or `None` for non-UUID principals.
    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
    }

    // --- Roles --------------------------------------------------------------

    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(", ")),
            })
        }
    }

    /// Platform-admin check: holds `ADMIN` or any `*_ADMIN` role (case-insensitive).
    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(),
            })
        }
    }

    // --- Scopes -------------------------------------------------------------

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

    /// Actix extractor: reads the `UserContext` that the auth middleware inserted into
    /// request extensions. Returns 401 when no context is present.
    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(),
                })),
            }
        }
    }
}