use std::marker::PhantomData;
use std::net::SocketAddr;
use axum::extract::{ConnectInfo, FromRef, FromRequestParts};
use axum::http::header;
use axum::http::request::Parts;
use sqlx::PgConnection;
use sqlx::pool::PoolConnection;
use yorishiro_core::YorishiroError;
use yorishiro_core::db::TenantDb;
use yorishiro_core::services::auth;
use yorishiro_core::services::auth::ApiKeyScope;
use crate::error::ApiError;
fn log_auth_rejection(parts: &Parts, err: &YorishiroError) {
let client = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.ip().to_string())
.unwrap_or_else(|| "unknown".to_string());
tracing::warn!(client = %client, path = %parts.uri.path(), error = %err, "request rejected during authentication");
}
fn extract_bearer_key(parts: &Parts) -> Result<&str, ApiError> {
parts
.headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| {
let err = YorishiroError::Unauthenticated;
log_auth_rejection(parts, &err);
ApiError(err)
})
}
pub struct AuthContext(pub auth::AuthContext);
impl<S> FromRequestParts<S> for AuthContext
where
TenantDb: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let presented_key = extract_bearer_key(parts)?;
let db = TenantDb::from_ref(state);
let ctx = auth::authenticate(db.pool(), presented_key)
.await
.inspect_err(|err| log_auth_rejection(parts, err))?;
match db
.acquire_for_workspace(ctx.tenant_id, ctx.workspace_id)
.await
{
Ok(mut conn) => {
if let Err(err) =
auth::touch_last_used(&mut conn, ctx.workspace_id, ctx.api_key_id).await
{
tracing::warn!(error = %err, "failed to update api key last_used_at");
}
}
Err(err) => {
tracing::warn!(error = %err, "failed to acquire connection to touch last_used_at");
}
}
Ok(AuthContext(ctx))
}
}
pub trait RequiredScope {
const SCOPE: ApiKeyScope;
}
pub struct ReadScope;
impl RequiredScope for ReadScope {
const SCOPE: ApiKeyScope = ApiKeyScope::Read;
}
pub struct WriteScope;
impl RequiredScope for WriteScope {
const SCOPE: ApiKeyScope = ApiKeyScope::Write;
}
pub struct SchemaScope;
impl RequiredScope for SchemaScope {
const SCOPE: ApiKeyScope = ApiKeyScope::Schema;
}
pub struct Authorized<R> {
pub ctx: auth::AuthContext,
conn: PoolConnection<sqlx::Postgres>,
_scope: PhantomData<R>,
}
impl<R> Authorized<R> {
pub fn conn(&mut self) -> &mut PgConnection {
&mut self.conn
}
}
impl<S, R> FromRequestParts<S> for Authorized<R>
where
TenantDb: FromRef<S>,
S: Send + Sync,
R: RequiredScope,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let presented_key = extract_bearer_key(parts)?;
let db = TenantDb::from_ref(state);
let (ctx, conn) = auth::authorize(&db, presented_key, R::SCOPE)
.await
.inspect_err(|err| log_auth_rejection(parts, err))?;
Ok(Authorized {
ctx,
conn,
_scope: PhantomData,
})
}
}
pub struct Verified<R> {
pub ctx: auth::AuthContext,
_scope: PhantomData<R>,
}
impl<S, R> FromRequestParts<S> for Verified<R>
where
TenantDb: FromRef<S>,
S: Send + Sync,
R: RequiredScope,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let presented_key = extract_bearer_key(parts)?;
let db = TenantDb::from_ref(state);
let ctx = auth::authorize_scope(&db, presented_key, R::SCOPE)
.await
.inspect_err(|err| log_auth_rejection(parts, err))?;
Ok(Verified {
ctx,
_scope: PhantomData,
})
}
}
#[cfg(test)]
mod tests {
use async_trait::async_trait;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use sqlx::PgPool;
use tower::ServiceExt;
use yorishiro_core::services::embedding::EmbeddingProvider;
use super::*;
use crate::state::AppState;
struct UnreachableEmbeddingProvider;
#[async_trait]
impl EmbeddingProvider for UnreachableEmbeddingProvider {
fn dimensions(&self) -> usize {
768
}
async fn embed_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>, YorishiroError> {
Err(YorishiroError::Internal(anyhow::anyhow!("unreachable")))
}
}
fn app(pool: PgPool) -> Router {
let state = AppState::new(
TenantDb::new(pool.clone()),
pool,
std::sync::Arc::new(UnreachableEmbeddingProvider),
);
Router::new()
.route(
"/probe",
get(|AuthContext(_): AuthContext| async { StatusCode::OK }),
)
.with_state(state)
}
#[tracing_test::traced_test]
#[sqlx::test(migrations = "../../migrations")]
async fn logs_a_warning_when_the_bearer_header_is_missing(pool: PgPool) {
let response = app(pool)
.oneshot(
Request::builder()
.uri("/probe")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(logs_contain("request rejected during authentication"));
assert!(logs_contain("path"));
assert!(!logs_contain("Bearer"));
}
#[tracing_test::traced_test]
#[sqlx::test(migrations = "../../migrations")]
async fn logs_a_warning_when_the_bearer_key_does_not_authenticate(pool: PgPool) {
let response = app(pool)
.oneshot(
Request::builder()
.uri("/probe")
.header("authorization", "Bearer ysr_not_a_real_key")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(logs_contain("request rejected during authentication"));
assert!(!logs_contain("ysr_not_a_real_key"));
}
}