road-runner-common 0.4.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Tenant (organization) scope for multi-tenant services.
//!
//! This is the transport/contract type only. How a service *resolves* a tenant context
//! (DB lookups, Redis caching, lazy provisioning) is service-specific domain logic and stays
//! in that service; it inserts the resolved [`TenantContext`] into request extensions, and
//! handlers pull it out via the extractor.

#![cfg(feature = "auth")]

use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::error::AppError;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TenantContext {
    pub organization_id: Uuid,
    #[serde(default)]
    pub keycloak_org_id: String,
    /// Subject of the user operating within this tenant.
    pub user_sub: String,
    #[serde(default)]
    pub permissions: Vec<String>,
}

impl TenantContext {
    pub fn has_permission(&self, permission: &str) -> bool {
        self.permissions.iter().any(|code| code == permission)
    }

    pub fn require_permission(&self, permission: &str) -> Result<(), AppError> {
        if self.has_permission(permission) {
            Ok(())
        } else {
            Err(AppError::Forbidden {
                message: format!("permission required: {permission}"),
            })
        }
    }

    pub fn require_org_match(&self, organization_id: Uuid) -> Result<(), AppError> {
        if self.organization_id == organization_id {
            Ok(())
        } else {
            Err(AppError::Forbidden {
                message: "active organization mismatch".to_string(),
            })
        }
    }
}

#[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 `TenantContext` the auth middleware inserted into request
    /// extensions. Returns 403 when no tenant context is present (caller has no active org).
    impl FromRequest for TenantContext {
        type Error = AppError;
        type Future = Ready<Result<Self, Self::Error>>;

        fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
            match req.extensions().get::<TenantContext>().cloned() {
                Some(ctx) => ready(Ok(ctx)),
                None => ready(Err(AppError::Forbidden {
                    message: "Missing tenant context (no active organization)".to_string(),
                })),
            }
        }
    }
}