diff --git a/src/authorization.rs b/src/authorization.rs index 3bbcb7d..0eb3ff1 100644 --- a/src/authorization.rs +++ b/src/authorization.rs @@ -1,3 +1,4 @@ +use crate::matching::OnAbsentValue; use crate::matching::permissive_match; use crate::request_metadata::RequestMetadata; @@ -22,12 +23,13 @@ impl Authorization { .map(|ua| ua.normalized.as_str()); self.rules.iter().any(|rule| { - permissive_match(&rule.providers, Some(provider)) + permissive_match(&rule.providers, Some(provider), OnAbsentValue::Check) && permissive_match( &rule.model_patterns, request_metadata.inspected.model.as_deref(), + OnAbsentValue::Allow, ) - && permissive_match(&rule.user_agents, user_agent) + && permissive_match(&rule.user_agents, user_agent, OnAbsentValue::Check) }) } @@ -103,7 +105,7 @@ mod tests { assert!(auth.is_allowed(&metadata("provider-a", Some("claude-sonnet-4-20250514")))); assert!(auth.is_allowed(&metadata("provider-a", Some("gpt-4o")))); assert!(!auth.is_allowed(&metadata("provider-a", Some("gpt-3.5-turbo")))); - assert!(!auth.is_allowed(&metadata("provider-a", None))); + assert!(auth.is_allowed(&metadata("provider-a", None))); // Wildcard provider with Some model assert!(auth.is_allowed(&metadata("provider-a-1", Some("claude-opus-4-20250514")))); @@ -116,6 +118,23 @@ mod tests { assert!(!auth.is_allowed(&metadata("other", Some("claude-sonnet-4-20250514")))); } + #[test] + fn test_is_allowed_without_model_skips_model_patterns() { + let auth = Authorization { + rules: vec![rule(&["provider-a"], &["claude-*"])], + }; + + // A request that carries no model is not filtered on model. + assert!(auth.is_allowed(&metadata("provider-a", None))); + + // A model that is present must still match. + assert!(auth.is_allowed(&metadata("provider-a", Some("claude-opus-5")))); + assert!(!auth.is_allowed(&metadata("provider-a", Some("gpt-4o")))); + + // Other attributes still apply when no model is present. + assert!(!auth.is_allowed(&metadata("provider-b", None))); + } + #[test] fn test_is_allowed_empty_list_allows_any() { // Empty models list means "all allowed" diff --git a/src/capabilities.rs b/src/capabilities.rs index c9148d1..fe6fd7e 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -1,3 +1,4 @@ +use crate::matching::OnAbsentValue; use crate::matching::permissive_match; use crate::model_rules::ModelRule; use serde::Deserialize; @@ -43,8 +44,8 @@ impl Grant { provider_key: &str, user_agent: Option<&str>, ) -> bool { - permissive_match(&self.providers, Some(provider_key)) - && permissive_match(&self.user_agents, user_agent) + permissive_match(&self.providers, Some(provider_key), OnAbsentValue::Check) + && permissive_match(&self.user_agents, user_agent, OnAbsentValue::Check) } } diff --git a/src/matching.rs b/src/matching.rs index ede5e26..dc3ecde 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -1,9 +1,22 @@ +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum OnAbsentValue { + Allow, + Check, +} + /// Returns true if `value` matches any of `patterns` /// Empty pattern list is treated as a permissive match-all. -pub fn permissive_match(patterns: &[glob::Pattern], value: Option<&str>) -> bool { +pub fn permissive_match( + patterns: &[glob::Pattern], + value: Option<&str>, + on_absent_value: OnAbsentValue, +) -> bool { patterns.is_empty() || match value { - None => patterns.iter().any(|p| p.matches("")), + None => match on_absent_value { + OnAbsentValue::Allow => true, + OnAbsentValue::Check => patterns.iter().any(|p| p.matches("")), + }, Some(v) => patterns.iter().any(|p| p.matches(v)), } } @@ -66,6 +79,36 @@ mod tests { most_specific_match(patterns, value, |p| p).map(|p| p.as_str()) } + #[test] + fn absent_value_is_allowed_or_checked() { + let patterns = vec![pattern("claude-*")]; + + assert!(permissive_match(&patterns, None, OnAbsentValue::Allow)); + assert!(!permissive_match(&patterns, None, OnAbsentValue::Check)); + + // A present value is unaffected by the argument. + assert!(permissive_match( + &patterns, + Some("claude-opus-5"), + OnAbsentValue::Check + )); + assert!(!permissive_match( + &patterns, + Some("gpt-4o"), + OnAbsentValue::Allow + )); + // An empty pattern list stays permissive either way. + assert!(permissive_match(&[], None, OnAbsentValue::Check)); + + // `Check` matches an absent value as the empty string, so a wildcard + // still accepts it. + assert!(permissive_match( + &[pattern("*")], + None, + OnAbsentValue::Check + )); + } + #[test] fn wildcard_position_does_not_matter_when_matching_most_specific_pattern() { // For "claude-opus-4-8", literal counts: claude-* (7), *-opus-4-8 (9),