fix: use intersection for user group policies

This commit is contained in:
fawney19
2026-05-11 16:59:35 +08:00
parent e16a225eb3
commit 9bed5e9f83
9 changed files with 294 additions and 74 deletions

View File

@@ -69,7 +69,7 @@ VALUES (
'00000000-0000-0000-0000-000000000001',
'Default',
'default',
'Default unrestricted group for all users',
'Default group for all users',
0,
'unrestricted',
'unrestricted',
@@ -91,7 +91,7 @@ VALUES (
'00000000-0000-0000-0000-000000000002',
'default_user_group_id',
'"00000000-0000-0000-0000-000000000001"',
'Default unrestricted user group',
'Default user group',
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
);

View File

@@ -74,7 +74,7 @@ VALUES (
'00000000-0000-0000-0000-000000000001',
'Default',
'default',
'Default unrestricted group for all users',
'Default group for all users',
0,
'unrestricted',
'unrestricted',
@@ -88,7 +88,7 @@ VALUES (
'00000000-0000-0000-0000-000000000002',
'default_user_group_id',
'"00000000-0000-0000-0000-000000000001"'::json,
'Default unrestricted user group',
'Default user group',
now(),
now()
)

View File

@@ -67,7 +67,7 @@ VALUES (
'00000000-0000-0000-0000-000000000001',
'Default',
'default',
'Default unrestricted group for all users',
'Default group for all users',
0,
'unrestricted',
'unrestricted',
@@ -89,7 +89,7 @@ VALUES (
'00000000-0000-0000-0000-000000000002',
'default_user_group_id',
'"00000000-0000-0000-0000-000000000001"',
'Default unrestricted user group',
'Default user group',
CAST(strftime('%s', 'now') AS INTEGER),
CAST(strftime('%s', 'now') AS INTEGER)
);

View File

@@ -15,7 +15,7 @@ VALUES (
'00000000-0000-0000-0000-000000000001',
'Default',
'default',
'Default unrestricted group for all users',
'Default group for all users',
0,
'unrestricted',
'unrestricted',
@@ -29,7 +29,7 @@ VALUES (
'00000000-0000-0000-0000-000000000002',
'default_user_group_id',
'"00000000-0000-0000-0000-000000000001"'::json,
'Default unrestricted user group'
'Default user group'
)
ON CONFLICT (key) DO NOTHING;

View File

@@ -154,7 +154,7 @@ pub struct ResolvedAuthApiKeySnapshot {
impl ResolvedAuthApiKeySnapshot {
pub fn from_stored(snapshot: StoredAuthApiKeySnapshot, now_unix_secs: u64) -> Self {
let currently_usable = snapshot.is_currently_usable(now_unix_secs);
Self {
let mut resolved = Self {
user_id: snapshot.user_id,
username: snapshot.username,
email: snapshot.email,
@@ -178,7 +178,9 @@ impl ResolvedAuthApiKeySnapshot {
api_key_allowed_api_formats: snapshot.api_key_allowed_api_formats,
api_key_allowed_models: snapshot.api_key_allowed_models,
currently_usable,
}
};
resolved.constrain_non_standalone_api_key_policy_to_user_policy();
resolved
}
pub fn effective_allowed_providers(&self) -> Option<&[String]> {
@@ -186,7 +188,8 @@ impl ResolvedAuthApiKeySnapshot {
return non_empty_allowed_list(self.api_key_allowed_providers.as_deref());
}
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())
}
@@ -195,7 +198,8 @@ impl ResolvedAuthApiKeySnapshot {
return non_empty_allowed_list(self.api_key_allowed_api_formats.as_deref());
}
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())
}
@@ -204,7 +208,8 @@ impl ResolvedAuthApiKeySnapshot {
return non_empty_allowed_list(self.api_key_allowed_models.as_deref());
}
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())
}
@@ -219,6 +224,25 @@ impl ResolvedAuthApiKeySnapshot {
self.user_allowed_api_formats = allowed_api_formats;
self.user_allowed_models = allowed_models;
self.user_rate_limit = rate_limit;
self.constrain_non_standalone_api_key_policy_to_user_policy();
}
fn constrain_non_standalone_api_key_policy_to_user_policy(&mut self) {
if self.api_key_is_standalone {
return;
}
constrain_api_key_list_policy_to_user_policy(
&mut self.user_allowed_providers,
&mut self.api_key_allowed_providers,
);
constrain_api_key_list_policy_to_user_policy(
&mut self.user_allowed_api_formats,
&mut self.api_key_allowed_api_formats,
);
constrain_api_key_list_policy_to_user_policy(
&mut self.user_allowed_models,
&mut self.api_key_allowed_models,
);
}
}
@@ -226,6 +250,29 @@ fn non_empty_allowed_list(values: Option<&[String]>) -> Option<&[String]> {
values.filter(|items| !items.is_empty())
}
fn constrain_api_key_list_policy_to_user_policy(
user_policy: &mut Option<Vec<String>>,
api_key_policy: &mut Option<Vec<String>>,
) {
let Some(api_key_values) = api_key_policy.as_ref() else {
return;
};
let Some(user_values) = user_policy.clone() else {
return;
};
let effective = intersect_allowed_lists(api_key_values, &user_values);
*user_policy = Some(effective.clone());
*api_key_policy = Some(effective);
}
fn intersect_allowed_lists(left: &[String], right: &[String]) -> Vec<String> {
let right_values = right.iter().collect::<std::collections::BTreeSet<_>>();
left.iter()
.filter(|value| right_values.contains(*value))
.cloned()
.collect()
}
#[async_trait]
pub trait ResolvedAuthApiKeySnapshotReader: Send + Sync {
async fn find_stored_auth_api_key_snapshot(
@@ -827,7 +874,7 @@ mod tests {
}
#[test]
fn resolved_snapshot_prefers_api_key_lists_over_user_lists() {
fn non_standalone_snapshot_intersects_api_key_and_user_allowed_lists() {
let snapshot = StoredAuthApiKeySnapshot::new(
"user-1".to_string(),
"alice".to_string(),
@@ -847,9 +894,9 @@ mod tests {
Some(60),
Some(5),
Some(200),
Some(serde_json::json!(["anthropic"])),
None,
None,
Some(serde_json::json!(["anthropic", "openai"])),
Some(serde_json::json!(["claude:messages"])),
Some(serde_json::json!(["gpt-5"])),
)
.expect("snapshot should build");
@@ -858,16 +905,56 @@ mod tests {
assert!(resolved.currently_usable);
assert_eq!(
resolved.effective_allowed_providers(),
Some(&["anthropic".to_string()][..])
Some(&["openai".to_string()][..])
);
assert_eq!(resolved.effective_allowed_api_formats(), Some(&[][..]));
assert_eq!(resolved.effective_allowed_models(), Some(&[][..]));
}
#[test]
fn applying_user_group_policy_keeps_non_standalone_key_at_intersection() {
let snapshot = StoredAuthApiKeySnapshot::new(
"user-1".to_string(),
"alice".to_string(),
None,
"user".to_string(),
"local".to_string(),
true,
false,
None,
None,
None,
"key-1".to_string(),
Some("default".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(200),
Some(serde_json::json!(["openai", "anthropic"])),
Some(serde_json::json!(["openai:chat", "claude:messages"])),
Some(serde_json::json!(["gpt-4.1", "claude-sonnet-4-5"])),
)
.expect("snapshot should build");
let mut resolved = ResolvedAuthApiKeySnapshot::from_stored(snapshot, 150);
resolved.apply_user_policy(
Some(vec!["openai".to_string(), "gemini".to_string()]),
Some(vec!["openai:chat".to_string()]),
Some(vec!["gpt-5".to_string()]),
Some(60),
);
assert_eq!(
resolved.effective_allowed_providers(),
Some(&["openai".to_string()][..])
);
assert_eq!(
resolved.effective_allowed_api_formats(),
Some(&["openai:chat".to_string()][..])
);
assert_eq!(
resolved.effective_allowed_models(),
Some(&["gpt-4.1".to_string()][..])
);
assert_eq!(resolved.effective_allowed_models(), Some(&[][..]));
}
#[test]
@@ -992,6 +1079,40 @@ mod tests {
);
}
#[test]
fn non_standalone_snapshot_keeps_empty_key_allowed_lists_as_deny_all() {
let snapshot = StoredAuthApiKeySnapshot::new(
"user-1".to_string(),
"alice".to_string(),
None,
"user".to_string(),
"local".to_string(),
true,
false,
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:chat"])),
Some(serde_json::json!(["gpt-4.1"])),
"user-key".to_string(),
Some("default".to_string()),
true,
false,
false,
Some(60),
None,
None,
Some(serde_json::json!([])),
Some(serde_json::json!([])),
Some(serde_json::json!([])),
)
.expect("snapshot should build");
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(&[][..]));
}
#[test]
fn standalone_snapshot_treats_empty_key_allowed_lists_as_unrestricted() {
let snapshot = StoredAuthApiKeySnapshot::new(