返回 CodeWhale
policy.rs
根目录 / crates / config / src / route / policy.rs
1 //! Catalog policy evaluated after every catalog layer (Phase 1 hook).
2 //!
3 //! Policy decides whether an allowed offering may be used. It never configures
4 //! endpoints or credentials, and it never makes an unusable route usable.
5 //! `DENY` is applied last and is never overridden by a catalog layer.
6
7 use serde::{Deserialize, Serialize};
8
9 /// Effect of one policy rule. Last match wins.
10 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11 #[serde(rename_all = "kebab-case")]
12 pub enum PolicyEffect {
13 /// Permit the matched resource.
14 Allow,
15 /// Forbid the matched resource. Never overridden by a later catalog layer.
16 Deny,
17 }
18
19 /// Action a policy rule addresses.
20 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21 #[serde(rename_all = "kebab-case")]
22 pub enum PolicyAction {
23 /// Using a model on a route (`<route-id>/<model-id>`).
24 ModelUse,
25 /// Using a route at all (`<route-id>`).
26 ProviderUse,
27 }
28
29 /// One wildcard-matched policy rule.
30 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31 pub struct PolicyRule {
32 /// Allow or deny.
33 pub effect: PolicyEffect,
34 /// Action being gated.
35 pub action: PolicyAction,
36 /// Resource glob: `"<route-id>/<model-id>"` or `"<route-id>"`.
37 pub resource: String,
38 }
39
40 /// Ordered policy document. Empty means allow-all.
41 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42 pub struct CatalogPolicy {
43 /// Rules in file order; last match wins.
44 #[serde(default)]
45 pub rules: Vec<PolicyRule>,
46 }
47
48 impl CatalogPolicy {
49 /// Empty policy (allow everything).
50 #[must_use]
51 pub fn allow_all() -> Self {
52 Self::default()
53 }
54
55 /// Whether `route_id` / `model_id` survives policy.
56 ///
57 /// Default is allow. Each matching rule overwrites the decision; the last
58 /// match wins. A final DENY cannot be undone by a catalog layer because
59 /// callers apply this *after* merge.
60 #[must_use]
61 pub fn allows(&self, route_id: &str, model_id: &str) -> bool {
62 let mut allowed = true;
63 for rule in &self.rules {
64 if rule.matches(route_id, model_id) {
65 allowed = rule.effect == PolicyEffect::Allow;
66 }
67 }
68 allowed
69 }
70 }
71
72 impl PolicyRule {
73 fn matches(&self, route_id: &str, model_id: &str) -> bool {
74 let resource = match self.action {
75 PolicyAction::ProviderUse => route_id.to_string(),
76 PolicyAction::ModelUse => format!("{route_id}/{model_id}"),
77 };
78 wildcard_match(&self.resource, &resource)
79 }
80 }
81
82 /// Single-`*` wildcard match. `*` does not cross `/`.
83 fn wildcard_match(pattern: &str, value: &str) -> bool {
84 wildcard_match_parts(pattern.as_bytes(), value.as_bytes())
85 }
86
87 fn wildcard_match_parts(pattern: &[u8], value: &[u8]) -> bool {
88 let mut p = 0;
89 let mut v = 0;
90 let mut star = None;
91 while v < value.len() {
92 if p < pattern.len() && pattern[p] == b'*' {
93 star = Some((p, v));
94 p += 1;
95 continue;
96 }
97 if p < pattern.len() && pattern[p] == value[v] {
98 p += 1;
99 v += 1;
100 continue;
101 }
102 if let Some((star_p, star_v)) = star {
103 if value[star_v] == b'/' {
104 return false;
105 }
106 v = star_v + 1;
107 p = star_p + 1;
108 star = Some((star_p, v));
109 continue;
110 }
111 return false;
112 }
113 while p < pattern.len() && pattern[p] == b'*' {
114 p += 1;
115 }
116 p == pattern.len()
117 }
118
119 #[cfg(test)]
120 mod tests {
121 use super::*;
122
123 #[test]
124 fn deny_survives_every_layer_order() {
125 let policy = CatalogPolicy {
126 rules: vec![PolicyRule {
127 effect: PolicyEffect::Deny,
128 action: PolicyAction::ModelUse,
129 resource: "*-cn/*".to_string(),
130 }],
131 };
132 assert!(!policy.allows("zai-coding-cn", "glm-5"));
133 assert!(policy.allows("zai", "glm-5"));
134 assert!(policy.allows("deepseek", "deepseek-v4-pro"));
135 }
136
137 #[test]
138 fn last_match_wins() {
139 let policy = CatalogPolicy {
140 rules: vec![
141 PolicyRule {
142 effect: PolicyEffect::Deny,
143 action: PolicyAction::ProviderUse,
144 resource: "deepseek".to_string(),
145 },
146 PolicyRule {
147 effect: PolicyEffect::Allow,
148 action: PolicyAction::ProviderUse,
149 resource: "deepseek".to_string(),
150 },
151 ],
152 };
153 assert!(policy.allows("deepseek", "any"));
154 }
155
156 #[test]
157 fn empty_policy_allows() {
158 assert!(CatalogPolicy::allow_all().allows("anything", "model"));
159 }
160 }
161
161 lines RUST