mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Tighten local auth allow-list matching
This commit is contained in:
@@ -3,6 +3,7 @@ use axum::http::Uri;
|
||||
|
||||
use super::super::GatewayControlDecision;
|
||||
use super::credentials::{contains_string, extract_requested_model};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) enum GatewayLocalAuthRejection {
|
||||
@@ -42,24 +43,297 @@ pub(crate) fn should_buffer_request_for_local_auth(
|
||||
&& crate::headers::is_json_request(headers)
|
||||
}
|
||||
|
||||
pub(crate) fn request_model_local_rejection(
|
||||
pub(crate) async fn request_model_local_rejection(
|
||||
state: &AppState,
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
uri: &Uri,
|
||||
headers: &http::HeaderMap,
|
||||
body: &Bytes,
|
||||
) -> Option<GatewayLocalAuthRejection> {
|
||||
let decision = decision?;
|
||||
) -> Result<Option<GatewayLocalAuthRejection>, GatewayError> {
|
||||
let Some(decision) = decision else {
|
||||
return Ok(None);
|
||||
};
|
||||
if decision.route_class.as_deref() != Some("ai_public") {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let auth_context = decision.auth_context.as_ref()?;
|
||||
let allowed_models = auth_context.allowed_models.as_deref()?;
|
||||
let requested_model = extract_requested_model(decision, uri, headers, body)?;
|
||||
let Some(auth_context) = decision.auth_context.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(allowed_models) = auth_context.allowed_models.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(requested_model) = extract_requested_model(decision, uri, headers, body) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if contains_string(allowed_models, &requested_model) {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
if request_model_resolves_to_allowed_model(state, decision, &requested_model, allowed_models)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Some(GatewayLocalAuthRejection::ModelNotAllowed {
|
||||
Ok(Some(GatewayLocalAuthRejection::ModelNotAllowed {
|
||||
model: requested_model,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
async fn request_model_resolves_to_allowed_model(
|
||||
state: &AppState,
|
||||
decision: &GatewayControlDecision,
|
||||
requested_model: &str,
|
||||
allowed_models: &[String],
|
||||
) -> Result<bool, GatewayError> {
|
||||
let Some(client_api_format) = decision
|
||||
.auth_endpoint_signature
|
||||
.as_deref()
|
||||
.map(crate::ai_pipeline::normalize_api_format_alias)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
for api_format in candidate_api_formats_for_model_resolution(&client_api_format) {
|
||||
let rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format(&api_format)
|
||||
.await?;
|
||||
let matching_rows = rows
|
||||
.into_iter()
|
||||
.filter(|row| {
|
||||
aether_scheduler_core::row_supports_requested_model(
|
||||
row,
|
||||
requested_model,
|
||||
&api_format,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let Some(resolved_global_model) =
|
||||
aether_scheduler_core::resolve_requested_global_model_name(
|
||||
&matching_rows,
|
||||
requested_model,
|
||||
&api_format,
|
||||
)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if contains_string(allowed_models, &resolved_global_model) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn candidate_api_formats_for_model_resolution(client_api_format: &str) -> Vec<String> {
|
||||
let mut api_formats = Vec::new();
|
||||
push_unique_api_format(&mut api_formats, client_api_format);
|
||||
for api_format in crate::ai_pipeline::request_candidate_api_formats(client_api_format, false) {
|
||||
push_unique_api_format(&mut api_formats, api_format);
|
||||
}
|
||||
api_formats
|
||||
}
|
||||
|
||||
fn push_unique_api_format(api_formats: &mut Vec<String>, api_format: &str) {
|
||||
let api_format = crate::ai_pipeline::normalize_api_format_alias(api_format);
|
||||
if api_format.is_empty() || api_formats.iter().any(|value| value == &api_format) {
|
||||
return;
|
||||
}
|
||||
api_formats.push(api_format);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
|
||||
};
|
||||
use axum::body::Bytes;
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
|
||||
use super::{request_model_local_rejection, GatewayLocalAuthRejection};
|
||||
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::AppState;
|
||||
|
||||
fn sample_row() -> StoredMinimalCandidateSelectionRow {
|
||||
StoredMinimalCandidateSelectionRow {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_name: "Provider 1".to_string(),
|
||||
provider_type: "openai".to_string(),
|
||||
provider_priority: 0,
|
||||
provider_is_active: true,
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
endpoint_api_format: "openai:chat".to_string(),
|
||||
endpoint_api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
endpoint_is_active: true,
|
||||
key_id: "key-1".to_string(),
|
||||
key_name: "key".to_string(),
|
||||
key_auth_type: "api_key".to_string(),
|
||||
key_is_active: true,
|
||||
key_api_formats: Some(vec!["openai:chat".to_string()]),
|
||||
key_allowed_models: None,
|
||||
key_capabilities: None,
|
||||
key_internal_priority: 0,
|
||||
key_global_priority_by_format: None,
|
||||
model_id: "model-1".to_string(),
|
||||
global_model_id: "global-model-1".to_string(),
|
||||
global_model_name: "gpt-5".to_string(),
|
||||
global_model_mappings: Some(vec!["gpt-5(?:\\.\\d+)?".to_string()]),
|
||||
global_model_supports_streaming: Some(true),
|
||||
model_provider_model_name: "gpt-5-upstream".to_string(),
|
||||
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
|
||||
name: "gpt-5-upstream".to_string(),
|
||||
priority: 1,
|
||||
api_formats: Some(vec!["openai:chat".to_string()]),
|
||||
}]),
|
||||
model_supports_streaming: Some(true),
|
||||
model_is_active: true,
|
||||
model_is_available: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_row_for_api_format(api_format: &str) -> StoredMinimalCandidateSelectionRow {
|
||||
let mut row = sample_row();
|
||||
let api_family = api_format
|
||||
.split_once(':')
|
||||
.map(|(family, _)| family)
|
||||
.unwrap_or(api_format);
|
||||
row.provider_id = format!("provider-{api_family}");
|
||||
row.provider_name = format!("Provider {api_family}");
|
||||
row.provider_type = api_family.to_string();
|
||||
row.endpoint_id = format!("endpoint-{api_family}");
|
||||
row.endpoint_api_format = api_format.to_string();
|
||||
row.endpoint_api_family = Some(api_family.to_string());
|
||||
row.key_id = format!("key-{api_family}");
|
||||
row.key_api_formats = Some(vec![api_format.to_string()]);
|
||||
if let Some(mappings) = row.model_provider_model_mappings.as_mut() {
|
||||
for mapping in mappings {
|
||||
mapping.api_formats = Some(vec![api_format.to_string()]);
|
||||
}
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
fn decision_with_allowed_models(allowed_models: Vec<String>) -> GatewayControlDecision {
|
||||
let mut decision = GatewayControlDecision::synthetic(
|
||||
"/v1/chat/completions",
|
||||
Some("ai_public".to_string()),
|
||||
Some("openai".to_string()),
|
||||
Some("chat".to_string()),
|
||||
Some("openai:chat".to_string()),
|
||||
);
|
||||
decision.auth_context = Some(GatewayControlAuthContext {
|
||||
user_id: "user-1".to_string(),
|
||||
api_key_id: "api-key-1".to_string(),
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
balance_remaining: None,
|
||||
access_allowed: true,
|
||||
user_rate_limit: None,
|
||||
api_key_rate_limit: None,
|
||||
api_key_is_standalone: false,
|
||||
local_rejection: None,
|
||||
allowed_models: Some(allowed_models),
|
||||
});
|
||||
decision
|
||||
}
|
||||
|
||||
fn state_with_rows(rows: Vec<StoredMinimalCandidateSelectionRow>) -> AppState {
|
||||
let repository = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(rows));
|
||||
let data = GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository);
|
||||
AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data)
|
||||
}
|
||||
|
||||
fn state_with_model_mapping() -> AppState {
|
||||
state_with_rows(vec![sample_row()])
|
||||
}
|
||||
|
||||
fn json_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"application/json"
|
||||
.parse()
|
||||
.expect("content type should parse"),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_rejection_allows_requested_model_that_resolves_to_allowed_global_model() {
|
||||
let state = state_with_model_mapping();
|
||||
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
|
||||
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
|
||||
let body = Bytes::from_static(br#"{"model":"gpt-5.2","messages":[]}"#);
|
||||
|
||||
let rejection =
|
||||
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
|
||||
.await
|
||||
.expect("model rejection should resolve");
|
||||
|
||||
assert_eq!(rejection, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_rejection_allows_cross_format_provider_mapping_to_allowed_global_model() {
|
||||
let mut row = sample_row_for_api_format("gemini:generate_content");
|
||||
row.model_provider_model_name = "gemini-2.5-pro-upstream".to_string();
|
||||
row.model_provider_model_mappings = Some(vec![StoredProviderModelMapping {
|
||||
name: "gemini-2.5-pro-alias".to_string(),
|
||||
priority: 1,
|
||||
api_formats: Some(vec!["gemini:generate_content".to_string()]),
|
||||
}]);
|
||||
let state = state_with_rows(vec![row]);
|
||||
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
|
||||
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
|
||||
let body = Bytes::from_static(br#"{"model":"gemini-2.5-pro-alias","messages":[]}"#);
|
||||
|
||||
let rejection =
|
||||
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
|
||||
.await
|
||||
.expect("model rejection should resolve");
|
||||
|
||||
assert_eq!(rejection, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_rejection_allows_cross_format_regex_mapping_to_allowed_global_model() {
|
||||
let state = state_with_rows(vec![sample_row_for_api_format("claude:messages")]);
|
||||
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
|
||||
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
|
||||
let body = Bytes::from_static(br#"{"model":"gpt-5.2","messages":[]}"#);
|
||||
|
||||
let rejection =
|
||||
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
|
||||
.await
|
||||
.expect("model rejection should resolve");
|
||||
|
||||
assert_eq!(rejection, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_rejection_denies_requested_model_outside_allowed_global_models() {
|
||||
let state = state_with_model_mapping();
|
||||
let decision = decision_with_allowed_models(vec!["gpt-4.1".to_string()]);
|
||||
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
|
||||
let body = Bytes::from_static(br#"{"model":"gpt-5.2","messages":[]}"#);
|
||||
|
||||
let rejection =
|
||||
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
|
||||
.await
|
||||
.expect("model rejection should resolve");
|
||||
|
||||
assert_eq!(
|
||||
rejection,
|
||||
Some(GatewayLocalAuthRejection::ModelNotAllowed {
|
||||
model: "gpt-5.2".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||
};
|
||||
use axum::http::Uri;
|
||||
use base64::Engine as _;
|
||||
use hmac::Mac;
|
||||
@@ -611,7 +614,7 @@ async fn build_data_backed_auth_context(
|
||||
.unwrap_or(auth_endpoint_signature)
|
||||
.trim();
|
||||
let requested_provider_allowed =
|
||||
auth_snapshot_allows_requested_provider(state, &snapshot, requested_provider).await;
|
||||
auth_snapshot_allows_requested_provider(state, &snapshot, auth_endpoint_signature).await;
|
||||
let local_rejection = if invalid_api_key {
|
||||
Some(GatewayLocalAuthRejection::InvalidApiKey)
|
||||
} else if locked_api_key {
|
||||
@@ -664,18 +667,23 @@ fn normalize_api_format_alias(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn api_format_matches(left: &str, right: &str) -> bool {
|
||||
normalize_api_format_alias(left) == normalize_api_format_alias(right)
|
||||
aether_scheduler_core::api_format_matches_allowed_value(left, right)
|
||||
}
|
||||
|
||||
async fn auth_snapshot_allows_requested_provider(
|
||||
state: &AppState,
|
||||
snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
|
||||
requested_provider: &str,
|
||||
auth_endpoint_signature: &str,
|
||||
) -> bool {
|
||||
let Some(allowed_providers) = snapshot.effective_allowed_providers() else {
|
||||
return true;
|
||||
};
|
||||
let requested_provider = requested_provider.trim();
|
||||
let requested_api_format = normalize_api_format_alias(auth_endpoint_signature);
|
||||
let requested_provider = requested_api_format
|
||||
.split_once(':')
|
||||
.map(|(provider, _)| provider)
|
||||
.unwrap_or(requested_api_format.as_str())
|
||||
.trim();
|
||||
if requested_provider.is_empty() {
|
||||
return true;
|
||||
}
|
||||
@@ -684,7 +692,7 @@ async fn auth_snapshot_allows_requested_provider(
|
||||
}
|
||||
if allowed_providers
|
||||
.iter()
|
||||
.any(|value| value.trim().eq_ignore_ascii_case(requested_provider))
|
||||
.any(|value| allowed_provider_value_matches_requested_provider(value, requested_provider))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -704,12 +712,10 @@ async fn auth_snapshot_allows_requested_provider(
|
||||
}
|
||||
};
|
||||
|
||||
providers.into_iter().any(|provider| {
|
||||
provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(requested_provider)
|
||||
&& allowed_providers.iter().any(|value| {
|
||||
let allowed_catalog_providers = providers
|
||||
.into_iter()
|
||||
.filter(|provider| {
|
||||
allowed_providers.iter().any(|value| {
|
||||
aether_scheduler_core::provider_matches_allowed_value(
|
||||
value,
|
||||
&provider.id,
|
||||
@@ -717,9 +723,95 @@ async fn auth_snapshot_allows_requested_provider(
|
||||
&provider.provider_type,
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if allowed_catalog_providers
|
||||
.iter()
|
||||
.any(|provider| provider_matches_requested_provider(provider, requested_provider))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let allowed_provider_ids = allowed_catalog_providers
|
||||
.iter()
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if allowed_provider_ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let endpoints = match state
|
||||
.list_provider_catalog_endpoints_by_provider_ids(&allowed_provider_ids)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"skip local provider auth gate for requested provider {}: provider endpoint lookup failed: {:?}",
|
||||
requested_provider, err
|
||||
);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
endpoints.iter().any(|endpoint| {
|
||||
endpoint_matches_requested_provider(endpoint, &requested_api_format, requested_provider)
|
||||
})
|
||||
}
|
||||
|
||||
fn allowed_provider_value_matches_requested_provider(
|
||||
allowed_value: &str,
|
||||
requested_provider: &str,
|
||||
) -> bool {
|
||||
aether_scheduler_core::provider_matches_allowed_value(
|
||||
allowed_value,
|
||||
requested_provider,
|
||||
requested_provider,
|
||||
requested_provider,
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_matches_requested_provider(
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
requested_provider: &str,
|
||||
) -> bool {
|
||||
aether_scheduler_core::provider_matches_allowed_value(
|
||||
requested_provider,
|
||||
&provider.id,
|
||||
&provider.name,
|
||||
&provider.provider_type,
|
||||
)
|
||||
}
|
||||
|
||||
fn endpoint_matches_requested_provider(
|
||||
endpoint: &StoredProviderCatalogEndpoint,
|
||||
requested_api_format: &str,
|
||||
requested_provider: &str,
|
||||
) -> bool {
|
||||
if !endpoint.is_active {
|
||||
return false;
|
||||
}
|
||||
if api_format_matches(&endpoint.api_format, requested_api_format) {
|
||||
return true;
|
||||
}
|
||||
let endpoint_api_format = normalize_api_format_alias(&endpoint.api_format);
|
||||
if crate::ai_pipeline::request_conversion_kind(requested_api_format, &endpoint_api_format)
|
||||
.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if endpoint.api_family.as_deref().is_some_and(|family| {
|
||||
allowed_provider_value_matches_requested_provider(family, requested_provider)
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
let endpoint_provider = endpoint_api_format
|
||||
.split_once(':')
|
||||
.map(|(provider, _)| provider)
|
||||
.unwrap_or(endpoint_api_format.as_str());
|
||||
allowed_provider_value_matches_requested_provider(endpoint_provider, requested_provider)
|
||||
}
|
||||
|
||||
fn get_cached_auth_context(state: &AppState, cache_key: &str) -> Option<GatewayControlAuthContext> {
|
||||
state
|
||||
.auth_context_cache
|
||||
@@ -734,7 +826,9 @@ mod tests {
|
||||
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
|
||||
};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||
};
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
|
||||
use super::{resolve_data_backed_auth_context, GatewayLocalAuthRejection};
|
||||
@@ -783,6 +877,22 @@ mod tests {
|
||||
.expect("provider should build")
|
||||
}
|
||||
|
||||
fn sample_endpoint(
|
||||
id: &str,
|
||||
provider_id: &str,
|
||||
api_format: &str,
|
||||
) -> StoredProviderCatalogEndpoint {
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
id.to_string(),
|
||||
provider_id.to_string(),
|
||||
api_format.to_string(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_backed_api_key_auth_touches_last_used_once_per_throttle_window() {
|
||||
let api_key = "sk-test-touch";
|
||||
@@ -868,6 +978,191 @@ mod tests {
|
||||
assert_eq!(auth_context.local_rejection, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_backed_auth_context_allows_provider_id_for_matching_endpoint_format() {
|
||||
let api_key = "sk-test-provider-endpoint";
|
||||
let mut snapshot = sample_snapshot("key-4", "user-4");
|
||||
snapshot.user_allowed_providers = Some(vec!["provider-custom-claude".to_string()]);
|
||||
snapshot.api_key_allowed_providers = Some(vec!["provider-custom-claude".to_string()]);
|
||||
snapshot.user_allowed_api_formats = None;
|
||||
snapshot.api_key_allowed_api_formats = None;
|
||||
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key(api_key)),
|
||||
snapshot,
|
||||
)]));
|
||||
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider(
|
||||
"provider-custom-claude",
|
||||
"Custom Claude Gateway",
|
||||
"custom",
|
||||
)],
|
||||
vec![sample_endpoint(
|
||||
"endpoint-custom-claude",
|
||||
"provider-custom-claude",
|
||||
"claude:messages",
|
||||
)],
|
||||
Vec::new(),
|
||||
));
|
||||
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository)
|
||||
.with_provider_catalog_reader(provider_catalog);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", api_key.parse().unwrap());
|
||||
|
||||
let auth_context = resolve_data_backed_auth_context(
|
||||
&state,
|
||||
&headers,
|
||||
&uri("/v1/messages"),
|
||||
Some("claude:messages"),
|
||||
)
|
||||
.await
|
||||
.expect("resolution should succeed")
|
||||
.expect("auth context should exist");
|
||||
|
||||
assert_eq!(auth_context.local_rejection, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_backed_auth_context_allows_provider_id_for_convertible_endpoint_format() {
|
||||
let api_key = "sk-test-provider-convertible-endpoint";
|
||||
let mut snapshot = sample_snapshot("key-9", "user-9");
|
||||
snapshot.api_key_is_standalone = true;
|
||||
snapshot.user_allowed_providers = None;
|
||||
snapshot.api_key_allowed_providers = Some(vec!["provider-custom-openai".to_string()]);
|
||||
snapshot.user_allowed_api_formats = None;
|
||||
snapshot.api_key_allowed_api_formats = None;
|
||||
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key(api_key)),
|
||||
snapshot,
|
||||
)]));
|
||||
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider(
|
||||
"provider-custom-openai",
|
||||
"Custom OpenAI Responses Gateway",
|
||||
"custom",
|
||||
)],
|
||||
vec![sample_endpoint(
|
||||
"endpoint-custom-openai-responses",
|
||||
"provider-custom-openai",
|
||||
"openai:responses",
|
||||
)],
|
||||
Vec::new(),
|
||||
));
|
||||
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository)
|
||||
.with_provider_catalog_reader(provider_catalog);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", api_key.parse().unwrap());
|
||||
|
||||
let auth_context = resolve_data_backed_auth_context(
|
||||
&state,
|
||||
&headers,
|
||||
&uri("/v1/messages?beta=true"),
|
||||
Some("claude:messages"),
|
||||
)
|
||||
.await
|
||||
.expect("resolution should succeed")
|
||||
.expect("auth context should exist");
|
||||
|
||||
assert_eq!(auth_context.local_rejection, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_backed_auth_context_denies_retired_anthropic_provider_alias_for_claude_route() {
|
||||
let api_key = "sk-test-provider-retired-anthropic-alias";
|
||||
let mut snapshot = sample_snapshot("key-5", "user-5");
|
||||
snapshot.user_allowed_providers = Some(vec!["anthropic".to_string()]);
|
||||
snapshot.api_key_allowed_providers = Some(vec!["anthropic".to_string()]);
|
||||
snapshot.user_allowed_api_formats = None;
|
||||
snapshot.api_key_allowed_api_formats = None;
|
||||
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key(api_key)),
|
||||
snapshot,
|
||||
)]));
|
||||
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-claude", "Claude", "custom")],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository)
|
||||
.with_provider_catalog_reader(provider_catalog);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", api_key.parse().unwrap());
|
||||
|
||||
let auth_context = resolve_data_backed_auth_context(
|
||||
&state,
|
||||
&headers,
|
||||
&uri("/v1/messages"),
|
||||
Some("claude:messages"),
|
||||
)
|
||||
.await
|
||||
.expect("resolution should succeed")
|
||||
.expect("auth context should exist");
|
||||
|
||||
assert_eq!(
|
||||
auth_context.local_rejection,
|
||||
Some(GatewayLocalAuthRejection::ProviderNotAllowed {
|
||||
provider: "claude".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_backed_auth_context_treats_empty_allowed_lists_as_unrestricted() {
|
||||
let api_key = "sk-test-empty-restrictions";
|
||||
let mut snapshot = sample_snapshot("key-6", "user-6");
|
||||
snapshot.api_key_is_standalone = true;
|
||||
snapshot.user_allowed_providers = Some(vec!["openai".to_string()]);
|
||||
snapshot.user_allowed_api_formats = Some(vec!["openai:chat".to_string()]);
|
||||
snapshot.user_allowed_models = Some(vec!["gpt-4.1".to_string()]);
|
||||
snapshot.api_key_allowed_providers = Some(Vec::new());
|
||||
snapshot.api_key_allowed_api_formats = Some(Vec::new());
|
||||
snapshot.api_key_allowed_models = Some(Vec::new());
|
||||
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key(api_key)),
|
||||
snapshot,
|
||||
)]));
|
||||
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-claude", "Claude", "custom")],
|
||||
vec![sample_endpoint(
|
||||
"endpoint-claude",
|
||||
"provider-claude",
|
||||
"claude:messages",
|
||||
)],
|
||||
Vec::new(),
|
||||
));
|
||||
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository)
|
||||
.with_provider_catalog_reader(provider_catalog);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", api_key.parse().unwrap());
|
||||
|
||||
let auth_context = resolve_data_backed_auth_context(
|
||||
&state,
|
||||
&headers,
|
||||
&uri("/v1/messages"),
|
||||
Some("claude:messages"),
|
||||
)
|
||||
.await
|
||||
.expect("resolution should succeed")
|
||||
.expect("auth context should exist");
|
||||
|
||||
assert_eq!(auth_context.local_rejection, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_backed_auth_context_denies_provider_type_without_matching_allowed_provider() {
|
||||
let api_key = "sk-test-provider-miss";
|
||||
|
||||
@@ -247,17 +247,15 @@ pub(crate) fn normalize_admin_user_api_formats(
|
||||
};
|
||||
let mut normalized = Vec::new();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let pattern =
|
||||
Regex::new(r"^[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+$").expect("api format regex should compile");
|
||||
for item in values {
|
||||
let item = item.trim();
|
||||
if item.is_empty() {
|
||||
return Err("allowed_api_formats 不能为空".to_string());
|
||||
}
|
||||
if !pattern.is_match(item) {
|
||||
let Some(normalized_item) = crate::api::ai::normalize_admin_endpoint_signature(item) else {
|
||||
return Err(format!("allowed_api_formats 格式无效: {item}"));
|
||||
}
|
||||
let normalized_item = item.to_ascii_lowercase();
|
||||
};
|
||||
let normalized_item = normalized_item.to_string();
|
||||
if seen.insert(normalized_item.clone()) {
|
||||
normalized.push(normalized_item);
|
||||
}
|
||||
@@ -278,3 +276,44 @@ pub(super) fn format_optional_datetime_iso8601(
|
||||
) -> Option<String> {
|
||||
value.map(|value| value.to_rfc3339())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_admin_user_api_formats;
|
||||
|
||||
#[test]
|
||||
fn admin_user_api_formats_accept_current_canonical_signatures() {
|
||||
assert_eq!(
|
||||
normalize_admin_user_api_formats(Some(vec![
|
||||
" OPENAI:RESPONSES ".to_string(),
|
||||
"claude:messages".to_string(),
|
||||
"gemini:generate_content".to_string(),
|
||||
"openai:responses".to_string(),
|
||||
]))
|
||||
.expect("formats should normalize"),
|
||||
Some(vec![
|
||||
"openai:responses".to_string(),
|
||||
"claude:messages".to_string(),
|
||||
"gemini:generate_content".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_user_api_formats_reject_retired_signatures() {
|
||||
for retired in [
|
||||
"anthropic:messages",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
] {
|
||||
assert!(
|
||||
normalize_admin_user_api_formats(Some(vec![retired.to_string()])).is_err(),
|
||||
"{retired} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,11 +1002,14 @@ pub(crate) async fn proxy_request(
|
||||
|
||||
if let Some(buffered_body) = buffered_body.as_ref() {
|
||||
if let Some(rejection) = request_model_local_rejection(
|
||||
&state,
|
||||
control_decision,
|
||||
&parts.uri,
|
||||
&parts.headers,
|
||||
buffered_body,
|
||||
) {
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let response =
|
||||
build_local_auth_rejection_response(&trace_id, control_decision, &rejection)?;
|
||||
return Ok(finalize_gateway_response_with_context(
|
||||
|
||||
@@ -7241,7 +7241,7 @@ async fn gateway_handles_users_me_providers_locally_without_proxying_upstream()
|
||||
sample_endpoint(
|
||||
"endpoint-claude-1",
|
||||
"provider-claude",
|
||||
"anthropic:messages",
|
||||
"claude:messages",
|
||||
"https://api.claude.example",
|
||||
),
|
||||
],
|
||||
|
||||
@@ -294,7 +294,7 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
Some(json!(["anthropic"])),
|
||||
Some(json!(["anthropic:messages"])),
|
||||
Some(json!(["claude:messages"])),
|
||||
Some(json!(["claude-3-7"])),
|
||||
Some(30),
|
||||
None,
|
||||
@@ -340,7 +340,7 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
||||
None,
|
||||
Some("secondary".to_string()),
|
||||
Some(json!(["anthropic"])),
|
||||
Some(json!(["anthropic:messages"])),
|
||||
Some(json!(["claude:messages"])),
|
||||
Some(json!(["claude-3-7"])),
|
||||
Some(60),
|
||||
Some(5),
|
||||
|
||||
Reference in New Issue
Block a user