athena_rs 3.23.0

Hyper performant polyglot Database driver
Documentation
use actix_web::{HttpRequest, HttpResponse};
use athena_auth_core::{
    ExtractedSessionToken, ValidateSessionInput, ValidatedSessionContext,
    extract_session_token_from_headers, validate_session,
};
use athena_chat::ChatContext;
use chrono::Utc;

use crate::AppState;
use crate::api::chat::auth_logging::{
    ChatSessionAuthOutcome, build_missing_token_details, build_session_source_details,
    spawn_chat_session_auth_log,
};
use crate::api::client_context::request_auth_pool;
use crate::api::client_context::required_client_name;
use crate::api::headers::request_context::athena_request_id;
use crate::api::response::{service_unavailable, unauthorized};

const SESSION_COOKIE_NAME: &str = "athena-auth.session-token";

#[derive(Debug, Default)]
pub struct AuthResolver;

impl AuthResolver {
    pub async fn resolve(
        &self,
        req: &HttpRequest,
        state: &AppState,
    ) -> Result<ChatContext, HttpResponse> {
        let client_name = required_client_name(req)?;
        let Some(session_token) = extract_chat_session_token(req) else {
            spawn_chat_session_auth_log(
                state,
                req,
                Some(client_name.as_str()),
                None,
                None,
                ChatSessionAuthOutcome::MissingSessionToken,
                Some("Missing or invalid Athena Auth session".to_string()),
                build_missing_token_details(
                    req.headers().contains_key("authorization"),
                    req.headers().contains_key("cookie"),
                    SESSION_COOKIE_NAME,
                ),
            );
            return Err(unauthorized(
                "Chat authentication required",
                "Missing or invalid Athena Auth session",
            ));
        };
        let pool = request_auth_pool(req, state).await?;
        let validated_session = validate_session(
            &pool,
            ValidateSessionInput {
                token: &session_token.token,
                now: Utc::now(),
                include_user: true,
                include_organization: true,
            },
        )
        .await
        .map_err(|err| {
            let error_message = format!("failed to verify Athena Auth session: {err}");
            spawn_chat_session_auth_log(
                state,
                req,
                Some(client_name.as_str()),
                Some(session_token.source),
                None,
                ChatSessionAuthOutcome::AuthStoreUnavailable,
                Some(error_message.clone()),
                build_session_source_details(session_token.source),
            );
            service_unavailable("Chat auth store unavailable", error_message)
        })?;

        let Some(validated_session) = validated_session else {
            spawn_chat_session_auth_log(
                state,
                req,
                Some(client_name.as_str()),
                Some(session_token.source),
                None,
                ChatSessionAuthOutcome::InvalidSession,
                Some("Missing or invalid Athena Auth session".to_string()),
                build_session_source_details(session_token.source),
            );
            return Err(unauthorized(
                "Chat authentication required",
                "Missing or invalid Athena Auth session",
            ));
        };

        let client_name_for_logging = client_name.clone();
        let chat_context = build_chat_context(
            validated_session.clone(),
            client_name,
            athena_request_id(req),
        )
        .map_err(|response| {
            spawn_chat_session_auth_log(
                state,
                req,
                Some(client_name_for_logging.as_str()),
                Some(session_token.source),
                Some(&validated_session),
                ChatSessionAuthOutcome::MissingOrganization,
                Some("No active organization resolved for actor".to_string()),
                build_session_source_details(session_token.source),
            );
            response
        })?;

        spawn_chat_session_auth_log(
            state,
            req,
            Some(chat_context.client_name.as_str()),
            Some(session_token.source),
            Some(&validated_session),
            ChatSessionAuthOutcome::Authenticated,
            None,
            build_session_source_details(session_token.source),
        );

        Ok(chat_context)
    }
}

fn extract_chat_session_token(req: &HttpRequest) -> Option<ExtractedSessionToken> {
    extract_session_token_from_headers(
        req.headers()
            .get("authorization")
            .and_then(|value| value.to_str().ok()),
        req.headers()
            .get("cookie")
            .and_then(|value| value.to_str().ok()),
        Some(SESSION_COOKIE_NAME),
    )
}

fn build_chat_context(
    validated_session: ValidatedSessionContext,
    client_name: String,
    request_id: Option<String>,
) -> Result<ChatContext, HttpResponse> {
    let organization_id = validated_session
        .active_organization_id
        .clone()
        .or_else(|| validated_session.organization_ids.first().cloned())
        .ok_or_else(|| {
            unauthorized(
                "Chat organization required",
                "No active organization resolved for actor",
            )
        })?;
    let role_ids = validated_session.merged_roles();

    Ok(ChatContext {
        user_id: validated_session.user_id,
        organization_id,
        client_name,
        is_admin: actor_is_admin(&role_ids, validated_session.has_admin_scope),
        role_ids,
        request_id,
    })
}

fn actor_is_admin(role_ids: &[String], has_admin_scope: bool) -> bool {
    has_admin_scope
        || role_ids.iter().any(|role| {
            matches!(
                role.as_str(),
                "admin" | "super_admin" | "owner" | "storage_admin" | "storage-admin"
            )
        })
}

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::test::TestRequest;
    use athena_auth_core::SessionTokenSource;

    fn sample_session() -> ValidatedSessionContext {
        ValidatedSessionContext {
            session_id: "session-1".to_string(),
            user_id: "user-1".to_string(),
            email: Some("user@example.com".to_string()),
            name: Some("User".to_string()),
            role: Some("member".to_string()),
            active_organization_id: Some("org-2".to_string()),
            organization_ids: vec!["org-1".to_string()],
            member_roles: vec!["owner".to_string()],
            permissions: Vec::new(),
            has_admin_scope: false,
            expires_at: Utc::now(),
        }
    }

    #[test]
    fn build_chat_context_prefers_active_organization() {
        let ctx = build_chat_context(
            sample_session(),
            "acme".to_string(),
            Some("req-1".to_string()),
        )
        .expect("chat context should resolve");

        assert_eq!(ctx.organization_id, "org-2");
        assert_eq!(
            ctx.role_ids,
            vec!["member".to_string(), "owner".to_string()]
        );
        assert!(ctx.is_admin);
    }

    #[test]
    fn build_chat_context_falls_back_to_first_member_organization() {
        let mut session = sample_session();
        session.active_organization_id = None;

        let ctx = build_chat_context(session, "acme".to_string(), None)
            .expect("chat context should resolve");

        assert_eq!(ctx.organization_id, "org-1");
    }

    #[test]
    fn build_chat_context_rejects_missing_organization_membership() {
        let mut session = sample_session();
        session.active_organization_id = None;
        session.organization_ids.clear();

        let response = build_chat_context(session, "acme".to_string(), None)
            .expect_err("missing org should fail");

        assert_eq!(response.status(), actix_web::http::StatusCode::UNAUTHORIZED);
    }

    #[test]
    fn extract_chat_session_token_accepts_cookie() {
        let req = TestRequest::default()
            .insert_header(("cookie", "athena-auth.session-token=session_cookie"))
            .to_http_request();

        let extracted = extract_chat_session_token(&req).expect("cookie token should resolve");

        assert_eq!(extracted.token, "session_cookie");
        assert_eq!(extracted.source, SessionTokenSource::Cookie);
    }
}