mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +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::super::GatewayControlDecision;
|
||||||
use super::credentials::{contains_string, extract_requested_model};
|
use super::credentials::{contains_string, extract_requested_model};
|
||||||
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub(crate) enum GatewayLocalAuthRejection {
|
pub(crate) enum GatewayLocalAuthRejection {
|
||||||
@@ -42,24 +43,297 @@ pub(crate) fn should_buffer_request_for_local_auth(
|
|||||||
&& crate::headers::is_json_request(headers)
|
&& 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>,
|
decision: Option<&GatewayControlDecision>,
|
||||||
uri: &Uri,
|
uri: &Uri,
|
||||||
headers: &http::HeaderMap,
|
headers: &http::HeaderMap,
|
||||||
body: &Bytes,
|
body: &Bytes,
|
||||||
) -> Option<GatewayLocalAuthRejection> {
|
) -> Result<Option<GatewayLocalAuthRejection>, GatewayError> {
|
||||||
let decision = decision?;
|
let Some(decision) = decision else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
if decision.route_class.as_deref() != Some("ai_public") {
|
if decision.route_class.as_deref() != Some("ai_public") {
|
||||||
return None;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let auth_context = decision.auth_context.as_ref()?;
|
let Some(auth_context) = decision.auth_context.as_ref() else {
|
||||||
let allowed_models = auth_context.allowed_models.as_deref()?;
|
return Ok(None);
|
||||||
let requested_model = extract_requested_model(decision, uri, headers, body)?;
|
};
|
||||||
|
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) {
|
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,
|
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 std::time::Duration;
|
||||||
|
|
||||||
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
|
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||||
|
};
|
||||||
use axum::http::Uri;
|
use axum::http::Uri;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use hmac::Mac;
|
use hmac::Mac;
|
||||||
@@ -611,7 +614,7 @@ async fn build_data_backed_auth_context(
|
|||||||
.unwrap_or(auth_endpoint_signature)
|
.unwrap_or(auth_endpoint_signature)
|
||||||
.trim();
|
.trim();
|
||||||
let requested_provider_allowed =
|
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 {
|
let local_rejection = if invalid_api_key {
|
||||||
Some(GatewayLocalAuthRejection::InvalidApiKey)
|
Some(GatewayLocalAuthRejection::InvalidApiKey)
|
||||||
} else if locked_api_key {
|
} 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 {
|
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(
|
async fn auth_snapshot_allows_requested_provider(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
|
snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
|
||||||
requested_provider: &str,
|
auth_endpoint_signature: &str,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let Some(allowed_providers) = snapshot.effective_allowed_providers() else {
|
let Some(allowed_providers) = snapshot.effective_allowed_providers() else {
|
||||||
return true;
|
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() {
|
if requested_provider.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -684,7 +692,7 @@ async fn auth_snapshot_allows_requested_provider(
|
|||||||
}
|
}
|
||||||
if allowed_providers
|
if allowed_providers
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| value.trim().eq_ignore_ascii_case(requested_provider))
|
.any(|value| allowed_provider_value_matches_requested_provider(value, requested_provider))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -704,12 +712,10 @@ async fn auth_snapshot_allows_requested_provider(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
providers.into_iter().any(|provider| {
|
let allowed_catalog_providers = providers
|
||||||
provider
|
.into_iter()
|
||||||
.provider_type
|
.filter(|provider| {
|
||||||
.trim()
|
allowed_providers.iter().any(|value| {
|
||||||
.eq_ignore_ascii_case(requested_provider)
|
|
||||||
&& allowed_providers.iter().any(|value| {
|
|
||||||
aether_scheduler_core::provider_matches_allowed_value(
|
aether_scheduler_core::provider_matches_allowed_value(
|
||||||
value,
|
value,
|
||||||
&provider.id,
|
&provider.id,
|
||||||
@@ -717,9 +723,95 @@ async fn auth_snapshot_allows_requested_provider(
|
|||||||
&provider.provider_type,
|
&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> {
|
fn get_cached_auth_context(state: &AppState, cache_key: &str) -> Option<GatewayControlAuthContext> {
|
||||||
state
|
state
|
||||||
.auth_context_cache
|
.auth_context_cache
|
||||||
@@ -734,7 +826,9 @@ mod tests {
|
|||||||
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
|
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
|
||||||
};
|
};
|
||||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
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 axum::http::{HeaderMap, Uri};
|
||||||
|
|
||||||
use super::{resolve_data_backed_auth_context, GatewayLocalAuthRejection};
|
use super::{resolve_data_backed_auth_context, GatewayLocalAuthRejection};
|
||||||
@@ -783,6 +877,22 @@ mod tests {
|
|||||||
.expect("provider should build")
|
.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]
|
#[tokio::test]
|
||||||
async fn data_backed_api_key_auth_touches_last_used_once_per_throttle_window() {
|
async fn data_backed_api_key_auth_touches_last_used_once_per_throttle_window() {
|
||||||
let api_key = "sk-test-touch";
|
let api_key = "sk-test-touch";
|
||||||
@@ -868,6 +978,191 @@ mod tests {
|
|||||||
assert_eq!(auth_context.local_rejection, None);
|
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]
|
#[tokio::test]
|
||||||
async fn data_backed_auth_context_denies_provider_type_without_matching_allowed_provider() {
|
async fn data_backed_auth_context_denies_provider_type_without_matching_allowed_provider() {
|
||||||
let api_key = "sk-test-provider-miss";
|
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 normalized = Vec::new();
|
||||||
let mut seen = std::collections::BTreeSet::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 {
|
for item in values {
|
||||||
let item = item.trim();
|
let item = item.trim();
|
||||||
if item.is_empty() {
|
if item.is_empty() {
|
||||||
return Err("allowed_api_formats 不能为空".to_string());
|
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}"));
|
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()) {
|
if seen.insert(normalized_item.clone()) {
|
||||||
normalized.push(normalized_item);
|
normalized.push(normalized_item);
|
||||||
}
|
}
|
||||||
@@ -278,3 +276,44 @@ pub(super) fn format_optional_datetime_iso8601(
|
|||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
value.map(|value| value.to_rfc3339())
|
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(buffered_body) = buffered_body.as_ref() {
|
||||||
if let Some(rejection) = request_model_local_rejection(
|
if let Some(rejection) = request_model_local_rejection(
|
||||||
|
&state,
|
||||||
control_decision,
|
control_decision,
|
||||||
&parts.uri,
|
&parts.uri,
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
buffered_body,
|
buffered_body,
|
||||||
) {
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
let response =
|
let response =
|
||||||
build_local_auth_rejection_response(&trace_id, control_decision, &rejection)?;
|
build_local_auth_rejection_response(&trace_id, control_decision, &rejection)?;
|
||||||
return Ok(finalize_gateway_response_with_context(
|
return Ok(finalize_gateway_response_with_context(
|
||||||
|
|||||||
@@ -7241,7 +7241,7 @@ async fn gateway_handles_users_me_providers_locally_without_proxying_upstream()
|
|||||||
sample_endpoint(
|
sample_endpoint(
|
||||||
"endpoint-claude-1",
|
"endpoint-claude-1",
|
||||||
"provider-claude",
|
"provider-claude",
|
||||||
"anthropic:messages",
|
"claude:messages",
|
||||||
"https://api.claude.example",
|
"https://api.claude.example",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
|||||||
"user".to_string(),
|
"user".to_string(),
|
||||||
"local".to_string(),
|
"local".to_string(),
|
||||||
Some(json!(["anthropic"])),
|
Some(json!(["anthropic"])),
|
||||||
Some(json!(["anthropic:messages"])),
|
Some(json!(["claude:messages"])),
|
||||||
Some(json!(["claude-3-7"])),
|
Some(json!(["claude-3-7"])),
|
||||||
Some(30),
|
Some(30),
|
||||||
None,
|
None,
|
||||||
@@ -340,7 +340,7 @@ async fn gateway_handles_admin_dashboard_stats_locally_without_proxying_upstream
|
|||||||
None,
|
None,
|
||||||
Some("secondary".to_string()),
|
Some("secondary".to_string()),
|
||||||
Some(json!(["anthropic"])),
|
Some(json!(["anthropic"])),
|
||||||
Some(json!(["anthropic:messages"])),
|
Some(json!(["claude:messages"])),
|
||||||
Some(json!(["claude-3-7"])),
|
Some(json!(["claude-3-7"])),
|
||||||
Some(60),
|
Some(60),
|
||||||
Some(5),
|
Some(5),
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
|||||||
value: "claude:messages",
|
value: "claude:messages",
|
||||||
label: "Claude Messages",
|
label: "Claude Messages",
|
||||||
default_path: "/v1/messages",
|
default_path: "/v1/messages",
|
||||||
aliases: &["claude", "anthropic", "claude_compatible"],
|
aliases: &["claude", "claude_compatible"],
|
||||||
},
|
},
|
||||||
AdminApiFormatDefinition {
|
AdminApiFormatDefinition {
|
||||||
value: "gemini:generate_content",
|
value: "gemini:generate_content",
|
||||||
|
|||||||
@@ -183,35 +183,36 @@ impl ResolvedAuthApiKeySnapshot {
|
|||||||
|
|
||||||
pub fn effective_allowed_providers(&self) -> Option<&[String]> {
|
pub fn effective_allowed_providers(&self) -> Option<&[String]> {
|
||||||
if self.api_key_is_standalone {
|
if self.api_key_is_standalone {
|
||||||
return self.api_key_allowed_providers.as_deref();
|
return non_empty_allowed_list(self.api_key_allowed_providers.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.api_key_allowed_providers
|
non_empty_allowed_list(self.api_key_allowed_providers.as_deref())
|
||||||
.as_deref()
|
.or_else(|| non_empty_allowed_list(self.user_allowed_providers.as_deref()))
|
||||||
.or(self.user_allowed_providers.as_deref())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn effective_allowed_api_formats(&self) -> Option<&[String]> {
|
pub fn effective_allowed_api_formats(&self) -> Option<&[String]> {
|
||||||
if self.api_key_is_standalone {
|
if self.api_key_is_standalone {
|
||||||
return self.api_key_allowed_api_formats.as_deref();
|
return non_empty_allowed_list(self.api_key_allowed_api_formats.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.api_key_allowed_api_formats
|
non_empty_allowed_list(self.api_key_allowed_api_formats.as_deref())
|
||||||
.as_deref()
|
.or_else(|| non_empty_allowed_list(self.user_allowed_api_formats.as_deref()))
|
||||||
.or(self.user_allowed_api_formats.as_deref())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn effective_allowed_models(&self) -> Option<&[String]> {
|
pub fn effective_allowed_models(&self) -> Option<&[String]> {
|
||||||
if self.api_key_is_standalone {
|
if self.api_key_is_standalone {
|
||||||
return self.api_key_allowed_models.as_deref();
|
return non_empty_allowed_list(self.api_key_allowed_models.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.api_key_allowed_models
|
non_empty_allowed_list(self.api_key_allowed_models.as_deref())
|
||||||
.as_deref()
|
.or_else(|| non_empty_allowed_list(self.user_allowed_models.as_deref()))
|
||||||
.or(self.user_allowed_models.as_deref())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn non_empty_allowed_list(values: Option<&[String]>) -> Option<&[String]> {
|
||||||
|
values.filter(|items| !items.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ResolvedAuthApiKeySnapshotReader: Send + Sync {
|
pub trait ResolvedAuthApiKeySnapshotReader: Send + Sync {
|
||||||
async fn find_stored_auth_api_key_snapshot(
|
async fn find_stored_auth_api_key_snapshot(
|
||||||
@@ -979,7 +980,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standalone_snapshot_keeps_empty_key_allowed_lists_as_deny_all() {
|
fn standalone_snapshot_treats_empty_key_allowed_lists_as_unrestricted() {
|
||||||
let snapshot = StoredAuthApiKeySnapshot::new(
|
let snapshot = StoredAuthApiKeySnapshot::new(
|
||||||
"admin-user".to_string(),
|
"admin-user".to_string(),
|
||||||
"admin".to_string(),
|
"admin".to_string(),
|
||||||
@@ -1007,9 +1008,9 @@ mod tests {
|
|||||||
|
|
||||||
let resolved = ResolvedAuthApiKeySnapshot::from_stored(snapshot, 150);
|
let resolved = ResolvedAuthApiKeySnapshot::from_stored(snapshot, 150);
|
||||||
|
|
||||||
assert_eq!(resolved.effective_allowed_providers(), Some(&[][..]));
|
assert_eq!(resolved.effective_allowed_providers(), None);
|
||||||
assert_eq!(resolved.effective_allowed_api_formats(), Some(&[][..]));
|
assert_eq!(resolved.effective_allowed_api_formats(), None);
|
||||||
assert_eq!(resolved.effective_allowed_models(), Some(&[][..]));
|
assert_eq!(resolved.effective_allowed_models(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -47,7 +47,16 @@ pub fn auth_constraints_allow_api_format(
|
|||||||
|
|
||||||
allowed
|
allowed
|
||||||
.iter()
|
.iter()
|
||||||
.any(|value| crate::normalize_api_format(value) == api_format)
|
.any(|value| api_format_matches_allowed_value(value, api_format))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_format_matches_allowed_value(allowed_value: &str, api_format: &str) -> bool {
|
||||||
|
let allowed_value = allowed_value.trim();
|
||||||
|
let api_format = api_format.trim();
|
||||||
|
if allowed_value.is_empty() || api_format.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
crate::normalize_api_format(allowed_value) == crate::normalize_api_format(api_format)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn auth_constraints_allow_model(
|
pub fn auth_constraints_allow_model(
|
||||||
@@ -68,8 +77,9 @@ pub fn auth_constraints_allow_model(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
auth_constraints_allow_api_format, auth_constraints_allow_model,
|
api_format_matches_allowed_value, auth_constraints_allow_api_format,
|
||||||
auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
|
auth_constraints_allow_model, auth_constraints_allow_provider,
|
||||||
|
provider_matches_allowed_value, SchedulerAuthConstraints,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn sample_constraints() -> SchedulerAuthConstraints {
|
fn sample_constraints() -> SchedulerAuthConstraints {
|
||||||
@@ -125,6 +135,52 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_allowed_value_matches_exact_identifiers_only() {
|
||||||
|
assert!(provider_matches_allowed_value(
|
||||||
|
"claude",
|
||||||
|
"provider-1",
|
||||||
|
"Claude",
|
||||||
|
"custom",
|
||||||
|
));
|
||||||
|
assert!(provider_matches_allowed_value(
|
||||||
|
"CLAUDE",
|
||||||
|
"provider-1",
|
||||||
|
"Claude",
|
||||||
|
"custom",
|
||||||
|
));
|
||||||
|
assert!(provider_matches_allowed_value(
|
||||||
|
"provider-1",
|
||||||
|
"provider-1",
|
||||||
|
"Other",
|
||||||
|
"claude",
|
||||||
|
));
|
||||||
|
assert!(!provider_matches_allowed_value(
|
||||||
|
"anthropic",
|
||||||
|
"provider-1",
|
||||||
|
"Other",
|
||||||
|
"claude",
|
||||||
|
));
|
||||||
|
assert!(!provider_matches_allowed_value(
|
||||||
|
"claude",
|
||||||
|
"provider-1",
|
||||||
|
"Anthropic",
|
||||||
|
"custom",
|
||||||
|
));
|
||||||
|
assert!(!provider_matches_allowed_value(
|
||||||
|
"anthropic:messages",
|
||||||
|
"provider-1",
|
||||||
|
"Other",
|
||||||
|
"claude",
|
||||||
|
));
|
||||||
|
assert!(!provider_matches_allowed_value(
|
||||||
|
"openai:responses",
|
||||||
|
"provider-1",
|
||||||
|
"Other",
|
||||||
|
"claude",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn constraints_normalize_api_formats_and_models() {
|
fn constraints_normalize_api_formats_and_models() {
|
||||||
let constraints = sample_constraints();
|
let constraints = sample_constraints();
|
||||||
@@ -143,4 +199,40 @@ mod tests {
|
|||||||
"gpt-4.1"
|
"gpt-4.1"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_format_allowed_value_rejects_retired_aliases() {
|
||||||
|
assert!(!api_format_matches_allowed_value(
|
||||||
|
"anthropic:messages",
|
||||||
|
"claude:messages"
|
||||||
|
));
|
||||||
|
assert!(!api_format_matches_allowed_value(
|
||||||
|
"claude:chat",
|
||||||
|
"claude:messages"
|
||||||
|
));
|
||||||
|
assert!(!api_format_matches_allowed_value(
|
||||||
|
"claude:cli",
|
||||||
|
"claude:messages"
|
||||||
|
));
|
||||||
|
assert!(!api_format_matches_allowed_value(
|
||||||
|
"openai:cli",
|
||||||
|
"openai:responses"
|
||||||
|
));
|
||||||
|
assert!(!api_format_matches_allowed_value(
|
||||||
|
"openai:compact",
|
||||||
|
"openai:responses:compact"
|
||||||
|
));
|
||||||
|
assert!(!api_format_matches_allowed_value(
|
||||||
|
"gemini:chat",
|
||||||
|
"gemini:generate_content"
|
||||||
|
));
|
||||||
|
assert!(api_format_matches_allowed_value(
|
||||||
|
"CLAUDE:MESSAGES",
|
||||||
|
"claude:messages"
|
||||||
|
));
|
||||||
|
assert!(!api_format_matches_allowed_value(
|
||||||
|
"openai:responses",
|
||||||
|
"claude:messages"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ pub use affinity::{
|
|||||||
matches_affinity_target, SchedulerAffinityTarget,
|
matches_affinity_target, SchedulerAffinityTarget,
|
||||||
};
|
};
|
||||||
pub use auth::{
|
pub use auth::{
|
||||||
auth_constraints_allow_api_format, auth_constraints_allow_model,
|
api_format_matches_allowed_value, auth_constraints_allow_api_format,
|
||||||
auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
|
auth_constraints_allow_model, auth_constraints_allow_provider, provider_matches_allowed_value,
|
||||||
|
SchedulerAuthConstraints,
|
||||||
};
|
};
|
||||||
pub use candidate::{
|
pub use candidate::{
|
||||||
auth_api_key_concurrency_limit_reached, candidate_is_selectable_with_runtime_state,
|
auth_api_key_concurrency_limit_reached, candidate_is_selectable_with_runtime_state,
|
||||||
|
|||||||
Reference in New Issue
Block a user