scout_rs 0.95.95

A Rust client for the synchornizing state with the Scout database
Documentation
//! User JWT access checks via PostgREST (orthogonal to service-role / device-key clients).

use std::fmt;

use anyhow::{anyhow, Result};
use postgrest::Postgrest;
use serde::Deserialize;

use crate::constants::env;

#[derive(Debug, Clone)]
pub struct UserAccessConfig {
    pub rest_url: String,
    pub supabase_anon_key: String,
}

impl UserAccessConfig {
    pub fn from_env() -> Result<Self> {
        dotenv::dotenv().ok();

        let mut rest_url = std::env::var(env::SCOUT_DATABASE_REST_URL).map_err(|_| {
            anyhow!(
                "{} environment variable is required",
                env::SCOUT_DATABASE_REST_URL
            )
        })?;

        if !rest_url.ends_with("/rest/v1") {
            if rest_url.ends_with('/') {
                rest_url.push_str("rest/v1");
            } else {
                rest_url.push_str("/rest/v1");
            }
        }

        let supabase_anon_key = std::env::var(env::SUPABASE_PUBLIC_API_KEY).map_err(|_| {
            anyhow!(
                "{} environment variable is required",
                env::SUPABASE_PUBLIC_API_KEY
            )
        })?;

        Ok(Self {
            rest_url,
            supabase_anon_key,
        })
    }
}

/// System name of the caller's herd role (`herd_roles.system_name`). Roles come from the
/// `platform_default_roles` catalog, so the set is not fixed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HerdRole(pub String);

impl HerdRole {
    pub fn system_name(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Clone)]
pub struct UserHerdAccess {
    pub herd_role: Option<HerdRole>,
    pub is_platform_superadmin: bool,
}

impl UserHerdAccess {
    pub fn can_view(&self) -> bool {
        self.is_platform_superadmin || self.herd_role.is_some()
    }
}

#[derive(Debug, Clone)]
pub struct UserDeviceAccess {
    pub herd_id: i64,
    pub herd: UserHerdAccess,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthError {
    InvalidToken,
    NotFound,
    ServiceUnavailable,
}

impl fmt::Display for AuthError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AuthError::InvalidToken => write!(f, "invalid or expired user access token"),
            AuthError::NotFound => write!(f, "device not found"),
            AuthError::ServiceUnavailable => write!(f, "authorization service unavailable"),
        }
    }
}

pub async fn get_user_herd_access(
    config: &UserAccessConfig,
    user_access_token: &str,
    herd_id: i64,
) -> Result<UserHerdAccess, AuthError> {
    let rows: Vec<AccessRow> = rpc_as_user(
        config,
        bearer_token(user_access_token)?,
        "get_user_herd_access",
        serde_json::json!({ "p_herd_id": herd_id }),
    )
    .await?;
    rows.into_iter()
        .next()
        .map(AccessRow::into_access)
        .ok_or(AuthError::ServiceUnavailable)
}

pub async fn get_user_device_access(
    config: &UserAccessConfig,
    user_access_token: &str,
    device_id: i64,
) -> Result<UserDeviceAccess, AuthError> {
    let row = rpc_as_user::<AccessRow>(
        config,
        bearer_token(user_access_token)?,
        "get_user_device_access",
        serde_json::json!({ "p_device_id": device_id }),
    )
    .await?
    .into_iter()
    .next()
    .ok_or(AuthError::NotFound)?;
    let herd_id = row.herd_id.ok_or(AuthError::ServiceUnavailable)?;
    Ok(UserDeviceAccess {
        herd_id,
        herd: row.into_access(),
    })
}

#[derive(Deserialize)]
struct AccessRow {
    #[serde(default)]
    herd_id: Option<i64>,
    herd_role: Option<String>,
    is_platform_superadmin: bool,
}

impl AccessRow {
    fn into_access(self) -> UserHerdAccess {
        UserHerdAccess {
            herd_role: self.herd_role.filter(|s| !s.is_empty()).map(HerdRole),
            is_platform_superadmin: self.is_platform_superadmin,
        }
    }
}

fn bearer_token(raw: &str) -> Result<&str, AuthError> {
    let s = raw.trim();
    let token = if s.len() >= 7 && s[..7].eq_ignore_ascii_case("bearer ") {
        s[7..].trim()
    } else {
        s
    };
    if token.is_empty() {
        Err(AuthError::InvalidToken)
    } else {
        Ok(token)
    }
}

async fn rpc_as_user<T>(
    config: &UserAccessConfig,
    token: &str,
    function_name: &str,
    params: serde_json::Value,
) -> Result<Vec<T>, AuthError>
where
    T: for<'de> Deserialize<'de>,
{
    let response = Postgrest::new(&config.rest_url)
        .insert_header("apikey", &config.supabase_anon_key)
        .insert_header("Authorization", format!("Bearer {}", token))
        .rpc(function_name, params.to_string())
        .execute()
        .await
        .map_err(|_| AuthError::ServiceUnavailable)?;

    let status = response.status();
    let body = response
        .text()
        .await
        .map_err(|_| AuthError::ServiceUnavailable)?;

    if status.as_u16() == 401 {
        return Err(AuthError::InvalidToken);
    }
    if !status.is_success() {
        return Err(AuthError::ServiceUnavailable);
    }

    serde_json::from_str(&body).map_err(|_| AuthError::ServiceUnavailable)
}