fix(gateway): align provider restrictions with provider catalog

This commit is contained in:
fawney19
2026-04-17 18:59:13 +08:00
parent cb647d95f9
commit 0ce61bc91c
9 changed files with 262 additions and 38 deletions

View File

@@ -9,12 +9,12 @@ pub(crate) fn auth_snapshot_allows_cross_format_candidate(
) -> bool { ) -> bool {
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() { if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
let provider_allowed = allowed_providers.iter().any(|value| { let provider_allowed = allowed_providers.iter().any(|value| {
value aether_scheduler_core::provider_matches_allowed_value(
.trim() value,
.eq_ignore_ascii_case(candidate.provider_id.trim()) &candidate.provider_id,
|| value &candidate.provider_name,
.trim() &candidate.provider_type,
.eq_ignore_ascii_case(candidate.provider_name.trim()) )
}); });
if !provider_allowed { if !provider_allowed {
return false; return false;

View File

@@ -516,13 +516,17 @@ pub(super) async fn resolve_data_backed_auth_context(
.await; .await;
let wallet_access = resolve_wallet_auth_gate(state, &snapshot).await?; let wallet_access = resolve_wallet_auth_gate(state, &snapshot).await?;
Ok(Some(build_data_backed_auth_context( Ok(Some(
snapshot, build_data_backed_auth_context(
signature, state,
None, snapshot,
None, signature,
wallet_access, None,
))) None,
wallet_access,
)
.await,
))
} }
Some( Some(
GatewayPrincipalCandidate::DeferredBearerToken { .. } GatewayPrincipalCandidate::DeferredBearerToken { .. }
@@ -564,16 +568,21 @@ async fn resolve_trusted_auth_context(
}; };
let wallet_access = resolve_wallet_auth_gate(state, &snapshot).await?; let wallet_access = resolve_wallet_auth_gate(state, &snapshot).await?;
Ok(Some(build_data_backed_auth_context( Ok(Some(
snapshot, build_data_backed_auth_context(
auth_endpoint_signature, state,
trusted_headers.access_allowed, snapshot,
trusted_headers.balance_remaining, auth_endpoint_signature,
wallet_access, trusted_headers.access_allowed,
))) trusted_headers.balance_remaining,
wallet_access,
)
.await,
))
} }
fn build_data_backed_auth_context( async fn build_data_backed_auth_context(
state: &AppState,
snapshot: crate::data::auth::GatewayAuthApiKeySnapshot, snapshot: crate::data::auth::GatewayAuthApiKeySnapshot,
auth_endpoint_signature: &str, auth_endpoint_signature: &str,
header_access_allowed: Option<bool>, header_access_allowed: Option<bool>,
@@ -601,6 +610,8 @@ fn build_data_backed_auth_context(
.map(|(provider, _)| provider) .map(|(provider, _)| provider)
.unwrap_or(auth_endpoint_signature) .unwrap_or(auth_endpoint_signature)
.trim(); .trim();
let requested_provider_allowed =
auth_snapshot_allows_requested_provider(state, &snapshot, requested_provider).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 {
@@ -614,11 +625,7 @@ fn build_data_backed_auth_context(
Some(GatewayLocalAuthRejection::BalanceDenied { Some(GatewayLocalAuthRejection::BalanceDenied {
remaining: balance_remaining.or(wallet_remaining), remaining: balance_remaining.or(wallet_remaining),
}) })
} else if !requested_provider.is_empty() } else if !requested_provider.is_empty() && !requested_provider_allowed {
&& snapshot
.effective_allowed_providers()
.is_some_and(|allowed| !contains_string(allowed, requested_provider))
{
Some(GatewayLocalAuthRejection::ProviderNotAllowed { Some(GatewayLocalAuthRejection::ProviderNotAllowed {
provider: requested_provider.to_string(), provider: requested_provider.to_string(),
}) })
@@ -648,6 +655,59 @@ fn build_data_backed_auth_context(
} }
} }
async fn auth_snapshot_allows_requested_provider(
state: &AppState,
snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
requested_provider: &str,
) -> bool {
let Some(allowed_providers) = snapshot.effective_allowed_providers() else {
return true;
};
let requested_provider = requested_provider.trim();
if requested_provider.is_empty() {
return true;
}
if allowed_providers.is_empty() {
return false;
}
if allowed_providers
.iter()
.any(|value| value.trim().eq_ignore_ascii_case(requested_provider))
{
return true;
}
if !state.has_provider_catalog_data_reader() {
return true;
}
let providers = match state.list_provider_catalog_providers(true).await {
Ok(value) => value,
Err(err) => {
debug!(
"skip local provider auth gate for requested provider {}: provider catalog lookup failed: {:?}",
requested_provider,
err
);
return true;
}
};
providers.into_iter().any(|provider| {
provider
.provider_type
.trim()
.eq_ignore_ascii_case(requested_provider)
&& allowed_providers.iter().any(|value| {
aether_scheduler_core::provider_matches_allowed_value(
value,
&provider.id,
&provider.name,
&provider.provider_type,
)
})
})
}
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
@@ -661,9 +721,11 @@ mod tests {
use aether_data::repository::auth::{ use aether_data::repository::auth::{
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot, InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
}; };
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
use axum::http::{HeaderMap, Uri}; use axum::http::{HeaderMap, Uri};
use super::resolve_data_backed_auth_context; use super::{resolve_data_backed_auth_context, GatewayLocalAuthRejection};
use crate::control::auth::credentials::hash_api_key; use crate::control::auth::credentials::hash_api_key;
use crate::data::GatewayDataState; use crate::data::GatewayDataState;
use crate::AppState; use crate::AppState;
@@ -699,6 +761,16 @@ mod tests {
path.parse().expect("uri should parse") path.parse().expect("uri should parse")
} }
fn sample_provider(id: &str, name: &str, provider_type: &str) -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
id.to_string(),
name.to_string(),
None,
provider_type.to_string(),
)
.expect("provider 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";
@@ -742,4 +814,93 @@ mod tests {
assert_eq!(second.api_key_id, "key-1"); assert_eq!(second.api_key_id, "key-1");
assert_eq!(repository.touch_count("key-1"), 1); assert_eq!(repository.touch_count("key-1"), 1);
} }
#[tokio::test]
async fn data_backed_auth_context_allows_provider_id_for_matching_provider_type() {
let api_key = "sk-test-provider-id";
let mut snapshot = sample_snapshot("key-2", "user-2");
snapshot.user_allowed_providers = Some(vec!["provider-openai-1".to_string()]);
snapshot.api_key_allowed_providers = Some(vec!["provider-openai-1".to_string()]);
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-openai-1",
"OpenAI Pool 1",
"openai",
)],
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/chat/completions"),
Some("openai:chat"),
)
.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";
let mut snapshot = sample_snapshot("key-3", "user-3");
snapshot.user_allowed_providers = Some(vec!["provider-claude-1".to_string()]);
snapshot.api_key_allowed_providers = Some(vec!["provider-claude-1".to_string()]);
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-openai-1", "OpenAI Pool 1", "openai"),
sample_provider("provider-claude-1", "Claude Pool 1", "claude"),
],
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(
http::header::AUTHORIZATION,
format!("Bearer {api_key}").parse().unwrap(),
);
let auth_context = resolve_data_backed_auth_context(
&state,
&headers,
&uri("/v1/chat/completions"),
Some("openai:chat"),
)
.await
.expect("resolution should succeed")
.expect("auth context should exist");
assert_eq!(
auth_context.local_rejection,
Some(GatewayLocalAuthRejection::ProviderNotAllowed {
provider: "openai".to_string(),
})
);
}
} }

View File

@@ -245,6 +245,7 @@ pub(crate) async fn read_global_model_names_for_api_format(
auth_constraints.as_ref(), auth_constraints.as_ref(),
&row.provider_id, &row.provider_id,
&row.provider_name, &row.provider_name,
&row.provider_type,
) { ) {
continue; continue;
} }

View File

@@ -50,6 +50,7 @@ fn auth_snapshot_allows_provider_for_models(
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
provider_id: &str, provider_id: &str,
provider_name: &str, provider_name: &str,
provider_type: &str,
) -> bool { ) -> bool {
let Some(allowed) = auth_snapshot let Some(allowed) = auth_snapshot
.and_then(crate::data::auth::GatewayAuthApiKeySnapshot::effective_allowed_providers) .and_then(crate::data::auth::GatewayAuthApiKeySnapshot::effective_allowed_providers)
@@ -58,8 +59,12 @@ fn auth_snapshot_allows_provider_for_models(
}; };
allowed.iter().any(|value| { allowed.iter().any(|value| {
value.trim().eq_ignore_ascii_case(provider_id.trim()) aether_scheduler_core::provider_matches_allowed_value(
|| value.trim().eq_ignore_ascii_case(provider_name.trim()) value,
provider_id,
provider_name,
provider_type,
)
}) })
} }
@@ -156,6 +161,7 @@ pub(super) fn filter_rows_for_models(
auth_snapshot, auth_snapshot,
&row.provider_id, &row.provider_id,
&row.provider_name, &row.provider_name,
&row.provider_type,
) )
}) })
.filter(|row| auth_snapshot_allows_model_for_models(auth_snapshot, &row.global_model_name)) .filter(|row| auth_snapshot_allows_model_for_models(auth_snapshot, &row.global_model_name))

View File

@@ -107,6 +107,7 @@ async fn resolve_users_me_allowed_global_model_ids(
.filter(|provider| { .filter(|provider| {
allowed_provider_names.contains(&provider.id.to_ascii_lowercase()) allowed_provider_names.contains(&provider.id.to_ascii_lowercase())
|| allowed_provider_names.contains(&provider.name.to_ascii_lowercase()) || allowed_provider_names.contains(&provider.name.to_ascii_lowercase())
|| allowed_provider_names.contains(&provider.provider_type.to_ascii_lowercase())
}) })
.map(|provider| provider.id) .map(|provider| provider.id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -286,6 +287,7 @@ pub(super) async fn handle_users_me_providers_get(
providers.retain(|provider| { providers.retain(|provider| {
allowed_provider_names.contains(&provider.id.to_ascii_lowercase()) allowed_provider_names.contains(&provider.id.to_ascii_lowercase())
|| allowed_provider_names.contains(&provider.name.to_ascii_lowercase()) || allowed_provider_names.contains(&provider.name.to_ascii_lowercase())
|| allowed_provider_names.contains(&provider.provider_type.to_ascii_lowercase())
}); });
} }
providers.sort_by(|left, right| { providers.sort_by(|left, right| {

View File

@@ -9,7 +9,8 @@ use serde_json::json;
use super::super::{ use super::super::{
build_router_with_state, hash_api_key, sample_currently_usable_auth_snapshot, build_router_with_state, hash_api_key, sample_currently_usable_auth_snapshot,
sample_expired_auth_snapshot, sample_locked_auth_snapshot, start_server, AppState, sample_expired_auth_snapshot, sample_locked_auth_snapshot, start_server, AppState,
GatewayDataState, InMemoryAuthApiKeySnapshotRepository, InMemoryWalletRepository, GatewayDataState, InMemoryAuthApiKeySnapshotRepository, InMemoryProviderCatalogReadRepository,
InMemoryWalletRepository, StoredProviderCatalogProvider,
}; };
use crate::constants::{ use crate::constants::{
CONTROL_ROUTE_CLASS_HEADER, EXECUTION_PATH_HEADER, EXECUTION_PATH_LOCAL_AUTH_DENIED, CONTROL_ROUTE_CLASS_HEADER, EXECUTION_PATH_HEADER, EXECUTION_PATH_LOCAL_AUTH_DENIED,
@@ -547,11 +548,24 @@ async fn gateway_locally_denies_disallowed_provider_without_hitting_control_or_u
Some(hash_api_key("sk-claude-provider-123")), Some(hash_api_key("sk-claude-provider-123")),
snapshot, snapshot,
)])); )]));
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![StoredProviderCatalogProvider::new(
"provider-openai-1".to_string(),
"OpenAI Pool 1".to_string(),
None,
"openai".to_string(),
)
.expect("provider should build")],
Vec::new(),
Vec::new(),
));
let data = GatewayDataState::with_auth_api_key_reader_for_tests(repository)
.with_provider_catalog_reader(provider_catalog);
let (upstream_url, upstream_handle) = start_server(upstream).await; let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state( let gateway = build_router_with_state(
AppState::new() AppState::new()
.expect("gateway state should build") .expect("gateway state should build")
.with_auth_api_key_data_reader_for_tests(repository), .with_data_state_for_tests(data),
); );
let (gateway_url, gateway_handle) = start_server(gateway).await; let (gateway_url, gateway_handle) = start_server(gateway).await;

View File

@@ -5,10 +5,24 @@ pub struct SchedulerAuthConstraints {
pub allowed_models: Option<Vec<String>>, pub allowed_models: Option<Vec<String>>,
} }
pub fn provider_matches_allowed_value(
allowed_value: &str,
provider_id: &str,
provider_name: &str,
provider_type: &str,
) -> bool {
let allowed_value = allowed_value.trim();
!allowed_value.is_empty()
&& (allowed_value.eq_ignore_ascii_case(provider_id.trim())
|| allowed_value.eq_ignore_ascii_case(provider_name.trim())
|| allowed_value.eq_ignore_ascii_case(provider_type.trim()))
}
pub fn auth_constraints_allow_provider( pub fn auth_constraints_allow_provider(
constraints: Option<&SchedulerAuthConstraints>, constraints: Option<&SchedulerAuthConstraints>,
provider_id: &str, provider_id: &str,
provider_name: &str, provider_name: &str,
provider_type: &str,
) -> bool { ) -> bool {
let Some(allowed) = let Some(allowed) =
constraints.and_then(|constraints| constraints.allowed_providers.as_deref()) constraints.and_then(|constraints| constraints.allowed_providers.as_deref())
@@ -17,8 +31,7 @@ pub fn auth_constraints_allow_provider(
}; };
allowed.iter().any(|value| { allowed.iter().any(|value| {
value.trim().eq_ignore_ascii_case(provider_id.trim()) provider_matches_allowed_value(value, provider_id, provider_name, provider_type)
|| value.trim().eq_ignore_ascii_case(provider_name.trim())
}) })
} }
@@ -56,7 +69,7 @@ pub fn auth_constraints_allow_model(
mod tests { mod tests {
use super::{ use super::{
auth_constraints_allow_api_format, auth_constraints_allow_model, auth_constraints_allow_api_format, auth_constraints_allow_model,
auth_constraints_allow_provider, SchedulerAuthConstraints, auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
}; };
fn sample_constraints() -> SchedulerAuthConstraints { fn sample_constraints() -> SchedulerAuthConstraints {
@@ -73,17 +86,42 @@ mod tests {
assert!(auth_constraints_allow_provider( assert!(auth_constraints_allow_provider(
Some(&constraints), Some(&constraints),
"provider-1", "provider-1",
"other" "other",
"other",
)); ));
assert!(auth_constraints_allow_provider( assert!(auth_constraints_allow_provider(
Some(&constraints), Some(&constraints),
"other", "other",
"openai" "openai",
"other",
));
assert!(auth_constraints_allow_provider(
Some(&constraints),
"other",
"other",
"openai",
)); ));
assert!(!auth_constraints_allow_provider( assert!(!auth_constraints_allow_provider(
Some(&constraints), Some(&constraints),
"other", "other",
"other" "other",
"other",
));
}
#[test]
fn provider_allowed_value_matches_type() {
assert!(provider_matches_allowed_value(
"openai",
"provider-1",
"OpenAI Pool",
"openai",
));
assert!(!provider_matches_allowed_value(
"claude",
"provider-1",
"OpenAI Pool",
"openai",
)); ));
} }

View File

@@ -159,6 +159,7 @@ pub fn build_minimal_candidate_selection(
auth_constraints, auth_constraints,
&row.provider_id, &row.provider_id,
&row.provider_name, &row.provider_name,
&row.provider_type,
) { ) {
continue; continue;
} }
@@ -269,6 +270,7 @@ pub fn collect_global_model_names_for_required_capability(
auth_constraints, auth_constraints,
&row.provider_id, &row.provider_id,
&row.provider_name, &row.provider_name,
&row.provider_type,
) { ) {
continue; continue;
} }

View File

@@ -12,7 +12,7 @@ pub use affinity::{
}; };
pub use auth::{ pub use auth::{
auth_constraints_allow_api_format, auth_constraints_allow_model, auth_constraints_allow_api_format, auth_constraints_allow_model,
auth_constraints_allow_provider, SchedulerAuthConstraints, auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
}; };
pub use candidate::{ pub use candidate::{
auth_api_key_concurrency_limit_reached, build_minimal_candidate_selection, auth_api_key_concurrency_limit_reached, build_minimal_candidate_selection,