Tighten local auth allow-list matching

This commit is contained in:
fawney19
2026-04-30 00:26:33 +08:00
parent 37392d8774
commit 9e2faa7e5b
10 changed files with 758 additions and 53 deletions

View File

@@ -559,7 +559,7 @@ const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
value: "claude:messages",
label: "Claude Messages",
default_path: "/v1/messages",
aliases: &["claude", "anthropic", "claude_compatible"],
aliases: &["claude", "claude_compatible"],
},
AdminApiFormatDefinition {
value: "gemini:generate_content",

View File

@@ -183,35 +183,36 @@ impl ResolvedAuthApiKeySnapshot {
pub fn effective_allowed_providers(&self) -> Option<&[String]> {
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
.as_deref()
.or(self.user_allowed_providers.as_deref())
non_empty_allowed_list(self.api_key_allowed_providers.as_deref())
.or_else(|| non_empty_allowed_list(self.user_allowed_providers.as_deref()))
}
pub fn effective_allowed_api_formats(&self) -> Option<&[String]> {
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
.as_deref()
.or(self.user_allowed_api_formats.as_deref())
non_empty_allowed_list(self.api_key_allowed_api_formats.as_deref())
.or_else(|| non_empty_allowed_list(self.user_allowed_api_formats.as_deref()))
}
pub fn effective_allowed_models(&self) -> Option<&[String]> {
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
.as_deref()
.or(self.user_allowed_models.as_deref())
non_empty_allowed_list(self.api_key_allowed_models.as_deref())
.or_else(|| non_empty_allowed_list(self.user_allowed_models.as_deref()))
}
}
fn non_empty_allowed_list(values: Option<&[String]>) -> Option<&[String]> {
values.filter(|items| !items.is_empty())
}
#[async_trait]
pub trait ResolvedAuthApiKeySnapshotReader: Send + Sync {
async fn find_stored_auth_api_key_snapshot(
@@ -979,7 +980,7 @@ mod tests {
}
#[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(
"admin-user".to_string(),
"admin".to_string(),
@@ -1007,9 +1008,9 @@ mod tests {
let resolved = ResolvedAuthApiKeySnapshot::from_stored(snapshot, 150);
assert_eq!(resolved.effective_allowed_providers(), Some(&[][..]));
assert_eq!(resolved.effective_allowed_api_formats(), Some(&[][..]));
assert_eq!(resolved.effective_allowed_models(), Some(&[][..]));
assert_eq!(resolved.effective_allowed_providers(), None);
assert_eq!(resolved.effective_allowed_api_formats(), None);
assert_eq!(resolved.effective_allowed_models(), None);
}
#[test]

View File

@@ -47,7 +47,16 @@ pub fn auth_constraints_allow_api_format(
allowed
.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(
@@ -68,8 +77,9 @@ pub fn auth_constraints_allow_model(
#[cfg(test)]
mod tests {
use super::{
auth_constraints_allow_api_format, auth_constraints_allow_model,
auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
api_format_matches_allowed_value, auth_constraints_allow_api_format,
auth_constraints_allow_model, auth_constraints_allow_provider,
provider_matches_allowed_value, 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]
fn constraints_normalize_api_formats_and_models() {
let constraints = sample_constraints();
@@ -143,4 +199,40 @@ mod tests {
"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"
));
}
}

View File

@@ -12,8 +12,9 @@ pub use affinity::{
matches_affinity_target, SchedulerAffinityTarget,
};
pub use auth::{
auth_constraints_allow_api_format, auth_constraints_allow_model,
auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
api_format_matches_allowed_value, auth_constraints_allow_api_format,
auth_constraints_allow_model, auth_constraints_allow_provider, provider_matches_allowed_value,
SchedulerAuthConstraints,
};
pub use candidate::{
auth_api_key_concurrency_limit_reached, candidate_is_selectable_with_runtime_state,