use actix_web::http::StatusCode;
use actix_web::{HttpRequest, HttpResponse};
use chrono::Utc;
use sha256::digest;
use sqlx::postgres::PgPool;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::error;
use uuid::Uuid;
use crate::AppState;
use crate::api::auth::extract_api_key;
use crate::api::response::{forbidden, service_unavailable, unauthorized};
use crate::data::api_keys::{
AuthAttemptLogEntry, get_api_key_by_public_id, get_effective_api_key_enforcement,
insert_auth_attempt_log, touch_api_key_last_used,
};
use crate::parser::query_builder::sanitize_identifier;
use crate::utils::request_logging::RequestAuthLogContext;
#[derive(Debug)]
pub struct GatewayAuthOutcome {
pub request_id: String,
pub log_context: RequestAuthLogContext,
pub response: Option<HttpResponse>,
}
#[derive(Debug, Clone)]
struct PresentedApiKey {
public_id: Option<String>,
secret: Option<String>,
fingerprint_hash: String,
fingerprint_salt: String,
}
pub fn read_right_for_resource(resource: Option<&str>) -> String {
resource_right(resource, "read", "gateway.read")
}
pub fn write_right_for_resource(resource: Option<&str>) -> String {
resource_right(resource, "write", "gateway.write")
}
pub fn delete_right_for_resource(resource: Option<&str>) -> String {
resource_right(resource, "delete", "gateway.delete")
}
pub fn query_right() -> String {
"gateway.query".to_string()
}
fn resource_right(resource: Option<&str>, action: &str, fallback: &str) -> String {
resource
.and_then(|value| {
if value.contains('.') {
return None;
}
sanitize_identifier(value).map(|sanitized| sanitized.trim_matches('"').to_string())
})
.map(|value| format!("{}.{}", value, action))
.unwrap_or_else(|| fallback.to_string())
}
fn split_right(right: &str) -> Option<(&str, &str)> {
right.split_once('.')
}
fn right_matches(granted: &str, required: &str) -> bool {
if granted == "*" || granted == required {
return true;
}
let Some((granted_resource, granted_action)) = split_right(granted) else {
return false;
};
let Some((required_resource, required_action)) = split_right(required) else {
return false;
};
if granted_resource == "*" && granted_action == required_action {
return true;
}
if granted_resource == required_resource && granted_action == "*" {
return true;
}
if granted_resource == "gateway" && granted_action == required_action {
return true;
}
if granted_resource == "gateway" && granted_action == "*" {
return true;
}
false
}
fn missing_required_rights(granted_rights: &[String], required_rights: &[String]) -> Vec<String> {
required_rights
.iter()
.filter(|required| {
!granted_rights
.iter()
.any(|granted| right_matches(granted, required))
})
.cloned()
.collect()
}
fn parse_presented_api_key(raw_value: &str) -> PresentedApiKey {
let fingerprint_salt = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let fingerprint_hash = digest(format!("{}:{}", fingerprint_salt, raw_value));
let (public_id, secret) = raw_value
.strip_prefix("ath_")
.and_then(|value| value.split_once('.'))
.map(|(public_id, secret)| (Some(public_id.to_string()), Some(secret.to_string())))
.unwrap_or((None, None));
PresentedApiKey {
public_id,
secret,
fingerprint_hash,
fingerprint_salt,
}
}
fn current_epoch_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| std::time::Duration::from_secs(0))
.as_secs() as i64
}
fn remote_addr(req: &HttpRequest) -> Option<String> {
req.headers()
.get("X-Real-IP")
.and_then(|value| value.to_str().ok())
.map(str::to_string)
.or_else(|| req.peer_addr().map(|addr| addr.ip().to_string()))
}
fn user_agent(req: &HttpRequest) -> Option<String> {
req.headers()
.get("User-Agent")
.and_then(|value| value.to_str().ok())
.map(str::to_string)
}
fn auth_store_pool(state: &AppState) -> Option<PgPool> {
state
.gateway_auth_client_name
.as_ref()
.and_then(|name| state.pg_registry.get_pool(name))
}
fn build_log_context(
request_id: String,
presented: Option<&PresentedApiKey>,
authenticated: bool,
authorized: bool,
enforced: bool,
api_key_id: Option<String>,
api_key_public_id: Option<String>,
reason: impl Into<String>,
) -> GatewayAuthOutcome {
GatewayAuthOutcome {
request_id,
log_context: RequestAuthLogContext {
api_key_id,
presented_api_key_public_id: api_key_public_id
.or_else(|| presented.and_then(|value| value.public_id.clone())),
presented_api_key_hash: presented.map(|value| value.fingerprint_hash.clone()),
presented_api_key_salt: presented.map(|value| value.fingerprint_salt.clone()),
api_key_authenticated: authenticated,
api_key_authorized: authorized,
api_key_enforced: enforced,
api_key_auth_reason: Some(reason.into()),
},
response: None,
}
}
fn rejection_response(
status: StatusCode,
message: impl Into<String>,
details: impl Into<String>,
) -> HttpResponse {
match status {
StatusCode::FORBIDDEN => forbidden(message, details),
StatusCode::SERVICE_UNAVAILABLE => service_unavailable(message, details),
_ => unauthorized(message, details),
}
}
fn spawn_auth_attempt_log(
pool: Option<PgPool>,
req: &HttpRequest,
request_id: &str,
client_name: Option<&str>,
required_rights: &[String],
granted_rights: &[String],
log_context: &RequestAuthLogContext,
) {
let Some(pool) = pool else {
return;
};
let entry = AuthAttemptLogEntry {
request_id: request_id.to_string(),
api_key_id: log_context.api_key_id.clone(),
api_key_public_id: log_context.presented_api_key_public_id.clone(),
client_name: client_name.map(str::to_string),
method: req.method().as_str().to_string(),
path: req.path().to_string(),
presented_key_hash: log_context.presented_api_key_hash.clone(),
presented_key_salt: log_context.presented_api_key_salt.clone(),
required_rights: required_rights.to_vec(),
granted_rights: granted_rights.to_vec(),
authenticated: log_context.api_key_authenticated,
authorized: log_context.api_key_authorized,
enforced: log_context.api_key_enforced,
failure_reason: log_context.api_key_auth_reason.clone(),
remote_addr: remote_addr(req),
user_agent: user_agent(req),
time: current_epoch_seconds(),
};
actix_web::rt::spawn(async move {
if let Err(err) = insert_auth_attempt_log(&pool, entry).await {
error!(error = %err, "failed to write api key auth log");
}
});
}
fn spawn_last_used_touch(pool: Option<PgPool>, api_key_id: Option<String>) {
let (Some(pool), Some(api_key_id)) = (pool, api_key_id) else {
return;
};
let Ok(api_key_id) = Uuid::parse_str(&api_key_id) else {
return;
};
actix_web::rt::spawn(async move {
if let Err(err) = touch_api_key_last_used(&pool, api_key_id).await {
error!(error = %err, "failed to update api key last_used_at");
}
});
}
pub async fn authorize_gateway_request(
req: &HttpRequest,
app_state: &AppState,
client_name: Option<&str>,
required_rights: Vec<String>,
) -> GatewayAuthOutcome {
let request_id = Uuid::new_v4().to_string();
let presented = extract_api_key(req).map(|value| parse_presented_api_key(&value));
let auth_pool = auth_store_pool(app_state);
let auth_store_configured = app_state.gateway_auth_client_name.is_some();
let Some(pool) = auth_pool.clone() else {
let reason = if auth_store_configured {
"api_key_store_unavailable"
} else {
"api_key_store_not_configured"
};
let mut outcome = build_log_context(
request_id,
presented.as_ref(),
false,
!auth_store_configured,
false,
None,
None,
reason,
);
if auth_store_configured {
outcome.response = Some(rejection_response(
StatusCode::SERVICE_UNAVAILABLE,
"API key validation unavailable",
"Gateway API key enforcement cannot be evaluated because the auth store is unavailable.",
));
}
return outcome;
};
let enforced = match get_effective_api_key_enforcement(&pool, client_name).await {
Ok(value) => value,
Err(err) => {
error!(error = %err, "failed to load effective api key enforcement");
let mut outcome = build_log_context(
request_id,
presented.as_ref(),
false,
false,
false,
None,
None,
"api_key_policy_lookup_failed",
);
outcome.response = Some(rejection_response(
StatusCode::SERVICE_UNAVAILABLE,
"API key policy unavailable",
"Gateway API key enforcement could not be evaluated.",
));
return outcome;
}
};
let required_rights = if required_rights.is_empty() {
vec!["gateway.access".to_string()]
} else {
required_rights
};
let Some(presented) = presented else {
let mut outcome = build_log_context(
request_id,
None,
false,
!enforced,
enforced,
None,
None,
if enforced {
"missing_api_key"
} else {
"api_key_not_enforced"
},
);
if enforced {
outcome.response = Some(rejection_response(
StatusCode::UNAUTHORIZED,
"API key required",
"Missing API key. Use Authorization: Bearer <key>, apikey, X-API-Key, X-Athena-Key, or ?api_key=.",
));
}
spawn_auth_attempt_log(
Some(pool),
req,
&outcome.request_id,
client_name,
&required_rights,
&[],
&outcome.log_context,
);
return outcome;
};
let Some(public_id) = presented.public_id.as_deref() else {
let mut outcome = build_log_context(
request_id,
Some(&presented),
false,
!enforced,
enforced,
None,
None,
if enforced {
"invalid_api_key_format"
} else {
"invalid_api_key_format_unenforced"
},
);
if enforced {
outcome.response = Some(rejection_response(
StatusCode::UNAUTHORIZED,
"Invalid API key",
"The API key format is invalid.",
));
}
spawn_auth_attempt_log(
Some(pool),
req,
&outcome.request_id,
client_name,
&required_rights,
&[],
&outcome.log_context,
);
return outcome;
};
let Some(secret) = presented.secret.as_deref() else {
let mut outcome = build_log_context(
request_id,
Some(&presented),
false,
!enforced,
enforced,
None,
Some(public_id.to_string()),
if enforced {
"invalid_api_key_format"
} else {
"invalid_api_key_format_unenforced"
},
);
if enforced {
outcome.response = Some(rejection_response(
StatusCode::UNAUTHORIZED,
"Invalid API key",
"The API key format is invalid.",
));
}
spawn_auth_attempt_log(
Some(pool),
req,
&outcome.request_id,
client_name,
&required_rights,
&[],
&outcome.log_context,
);
return outcome;
};
let record = match get_api_key_by_public_id(&pool, public_id).await {
Ok(value) => value,
Err(err) => {
error!(error = %err, "failed to look up api key");
let mut outcome = build_log_context(
request_id,
Some(&presented),
false,
false,
enforced,
None,
Some(public_id.to_string()),
"api_key_lookup_failed",
);
outcome.response = Some(rejection_response(
StatusCode::SERVICE_UNAVAILABLE,
"API key validation unavailable",
"The API key store could not validate the presented key.",
));
return outcome;
}
};
let Some(record) = record else {
let mut outcome = build_log_context(
request_id,
Some(&presented),
false,
!enforced,
enforced,
None,
Some(public_id.to_string()),
if enforced {
"unknown_api_key"
} else {
"unknown_api_key_unenforced"
},
);
if enforced {
outcome.response = Some(rejection_response(
StatusCode::UNAUTHORIZED,
"Invalid API key",
"No API key matched the provided identifier.",
));
}
spawn_auth_attempt_log(
Some(pool),
req,
&outcome.request_id,
client_name,
&required_rights,
&[],
&outcome.log_context,
);
return outcome;
};
let expected_hash = digest(format!("{}:{}", record.key_salt, secret));
if expected_hash != record.key_hash {
let mut outcome = build_log_context(
request_id,
Some(&presented),
false,
!enforced,
enforced,
Some(record.record.id.clone()),
Some(record.record.public_id.clone()),
if enforced {
"invalid_api_key_secret"
} else {
"invalid_api_key_secret_unenforced"
},
);
if enforced {
outcome.response = Some(rejection_response(
StatusCode::UNAUTHORIZED,
"Invalid API key",
"The provided API key secret does not match.",
));
}
spawn_auth_attempt_log(
Some(pool),
req,
&outcome.request_id,
client_name,
&required_rights,
&record.record.rights,
&outcome.log_context,
);
return outcome;
}
if !record.record.is_active {
let mut outcome = build_log_context(
request_id,
Some(&presented),
false,
!enforced,
enforced,
Some(record.record.id.clone()),
Some(record.record.public_id.clone()),
if enforced {
"inactive_api_key"
} else {
"inactive_api_key_unenforced"
},
);
if enforced {
outcome.response = Some(rejection_response(
StatusCode::UNAUTHORIZED,
"Inactive API key",
"The API key has been disabled.",
));
}
spawn_auth_attempt_log(
Some(pool),
req,
&outcome.request_id,
client_name,
&required_rights,
&record.record.rights,
&outcome.log_context,
);
return outcome;
}
if let Some(expires_at) = record.record.expires_at {
if expires_at < Utc::now() {
let mut outcome = build_log_context(
request_id,
Some(&presented),
false,
!enforced,
enforced,
Some(record.record.id.clone()),
Some(record.record.public_id.clone()),
if enforced {
"expired_api_key"
} else {
"expired_api_key_unenforced"
},
);
if enforced {
outcome.response = Some(rejection_response(
StatusCode::UNAUTHORIZED,
"Expired API key",
"The API key has expired.",
));
}
spawn_auth_attempt_log(
Some(pool),
req,
&outcome.request_id,
client_name,
&required_rights,
&record.record.rights,
&outcome.log_context,
);
return outcome;
}
}
if let Some(bound_client_name) = record.record.client_name.as_deref() {
if client_name.filter(|value| !value.is_empty()) != Some(bound_client_name) {
let mut outcome = build_log_context(
request_id,
Some(&presented),
true,
!enforced,
enforced,
Some(record.record.id.clone()),
Some(record.record.public_id.clone()),
if enforced {
"api_key_client_mismatch"
} else {
"api_key_client_mismatch_unenforced"
},
);
if enforced {
outcome.response = Some(rejection_response(
StatusCode::FORBIDDEN,
"API key client mismatch",
format!(
"The API key is scoped to client '{}', not '{}'.",
bound_client_name,
client_name.unwrap_or_default()
),
));
}
spawn_auth_attempt_log(
Some(pool),
req,
&outcome.request_id,
client_name,
&required_rights,
&record.record.rights,
&outcome.log_context,
);
return outcome;
}
}
let missing_rights = missing_required_rights(&record.record.rights, &required_rights);
if !missing_rights.is_empty() {
let mut outcome = build_log_context(
request_id,
Some(&presented),
true,
!enforced,
enforced,
Some(record.record.id.clone()),
Some(record.record.public_id.clone()),
if enforced {
"missing_api_key_rights"
} else {
"missing_api_key_rights_unenforced"
},
);
if enforced {
outcome.response = Some(rejection_response(
StatusCode::FORBIDDEN,
"Insufficient API key rights",
format!("Missing required rights: {}", missing_rights.join(", ")),
));
}
spawn_auth_attempt_log(
Some(pool.clone()),
req,
&outcome.request_id,
client_name,
&required_rights,
&record.record.rights,
&outcome.log_context,
);
spawn_last_used_touch(Some(pool), Some(record.record.id.clone()));
return outcome;
}
let outcome = build_log_context(
request_id,
Some(&presented),
true,
true,
enforced,
Some(record.record.id.clone()),
Some(record.record.public_id.clone()),
if enforced {
"api_key_authenticated"
} else {
"api_key_authenticated_unenforced"
},
);
spawn_auth_attempt_log(
Some(pool.clone()),
req,
&outcome.request_id,
client_name,
&required_rights,
&record.record.rights,
&outcome.log_context,
);
spawn_last_used_touch(Some(pool), Some(record.record.id));
outcome
}
#[cfg(test)]
mod tests {
use super::{
delete_right_for_resource, parse_presented_api_key, query_right, read_right_for_resource,
right_matches, write_right_for_resource,
};
#[test]
fn api_key_parser_extracts_identifier_and_secret() {
let parsed = parse_presented_api_key("ath_public123.secret456");
assert_eq!(parsed.public_id.as_deref(), Some("public123"));
assert_eq!(parsed.secret.as_deref(), Some("secret456"));
assert!(!parsed.fingerprint_hash.is_empty());
assert!(!parsed.fingerprint_salt.is_empty());
}
#[test]
fn wildcard_rights_match_expected_shapes() {
assert!(right_matches("users.read", "users.read"));
assert!(right_matches("users.*", "users.read"));
assert!(right_matches("*.read", "users.read"));
assert!(right_matches("gateway.read", "users.read"));
assert!(right_matches("gateway.*", "users.delete"));
assert!(right_matches("*", "users.read"));
assert!(!right_matches("users.write", "users.read"));
assert!(!right_matches("tickets.read", "users.read"));
}
#[test]
fn resource_right_helpers_fall_back_to_gateway_scopes() {
assert_eq!(read_right_for_resource(Some("users")), "users.read");
assert_eq!(write_right_for_resource(Some("users")), "users.write");
assert_eq!(delete_right_for_resource(Some("users")), "users.delete");
assert_eq!(
read_right_for_resource(Some("public.users")),
"gateway.read"
);
assert_eq!(query_right(), "gateway.query");
}
}