Skip to main content

ash_core/config/
scope_policies.rs

1//! Server-side scope policy registry for ASH.
2//!
3//! Allows servers to define which fields must be protected for each route,
4//! without requiring client-side scope management.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ash_core::config::{register_scope_policy, get_scope_policy, clear_scope_policies};
10//!
11//! // Clear any existing policies (useful in tests)
12//! clear_scope_policies();
13//!
14//! // Register policies at application startup
15//! register_scope_policy("POST|/api/transfer|", &["amount", "recipient"]);
16//! register_scope_policy("POST|/api/payment|", &["amount", "card_last4"]);
17//! register_scope_policy("PUT|/api/users/<id>|", &["role", "permissions"]);
18//!
19//! // Later, get policy for a binding
20//! let scope = get_scope_policy("POST|/api/transfer|");
21//! assert_eq!(scope, vec!["amount", "recipient"]);
22//! ```
23
24use regex::Regex;
25use std::collections::HashMap;
26use std::sync::RwLock;
27
28/// Scope policy registry that can be instantiated for isolated testing.
29///
30/// For most use cases, use the global functions (`register_scope_policy`, etc.)
31/// which operate on a shared global registry.
32#[derive(Debug, Default)]
33pub struct ScopePolicyRegistry {
34    policies: HashMap<String, Vec<String>>,
35}
36
37impl ScopePolicyRegistry {
38    /// Create a new empty registry.
39    pub fn new() -> Self {
40        Self {
41            policies: HashMap::new(),
42        }
43    }
44
45    /// Register a scope policy for a binding pattern.
46    pub fn register(&mut self, binding: &str, fields: &[&str]) {
47        self.policies.insert(
48            binding.to_string(),
49            fields.iter().map(|s| s.to_string()).collect(),
50        );
51    }
52
53    /// Register multiple scope policies at once.
54    pub fn register_many(&mut self, policies_map: &HashMap<&str, Vec<&str>>) {
55        for (binding, fields) in policies_map {
56            self.policies.insert(
57                binding.to_string(),
58                fields.iter().map(|s| s.to_string()).collect(),
59            );
60        }
61    }
62
63    /// Get the scope policy for a binding.
64    pub fn get(&self, binding: &str) -> Vec<String> {
65        // Exact match first
66        if let Some(fields) = self.policies.get(binding) {
67            return fields.clone();
68        }
69
70        // Pattern match
71        for (pattern, fields) in self.policies.iter() {
72            if matches_pattern(binding, pattern) {
73                return fields.clone();
74            }
75        }
76
77        // Default: no scoping (full payload protection)
78        Vec::new()
79    }
80
81    /// Check if a binding has a scope policy defined.
82    pub fn has(&self, binding: &str) -> bool {
83        if self.policies.contains_key(binding) {
84            return true;
85        }
86
87        for pattern in self.policies.keys() {
88            if matches_pattern(binding, pattern) {
89                return true;
90            }
91        }
92
93        false
94    }
95
96    /// Get all registered policies.
97    pub fn get_all(&self) -> HashMap<String, Vec<String>> {
98        self.policies.clone()
99    }
100
101    /// Clear all registered policies.
102    pub fn clear(&mut self) {
103        self.policies.clear();
104    }
105}
106
107lazy_static::lazy_static! {
108    /// Global scope policy registry
109    static ref GLOBAL_REGISTRY: RwLock<ScopePolicyRegistry> = RwLock::new(ScopePolicyRegistry::new());
110}
111
112/// Register a scope policy for a binding pattern.
113///
114/// # Arguments
115///
116/// * `binding` - The binding pattern (supports `<param>`, `:param`, `{param}`, `*`, `**` wildcards)
117/// * `fields` - The fields that must be protected
118///
119/// # Example
120///
121/// ```rust
122/// use ash_core::config::{register_scope_policy, clear_scope_policies};
123///
124/// clear_scope_policies();
125/// register_scope_policy("POST|/api/transfer|", &["amount", "recipient"]);
126/// register_scope_policy("PUT|/api/users/<id>|", &["role", "permissions"]);
127/// ```
128pub fn register_scope_policy(binding: &str, fields: &[&str]) {
129    let mut registry = GLOBAL_REGISTRY.write().unwrap();
130    registry.register(binding, fields);
131}
132
133/// Register multiple scope policies at once.
134///
135/// # Arguments
136///
137/// * `policies` - Map of binding => fields
138///
139/// # Example
140///
141/// ```rust
142/// use std::collections::HashMap;
143/// use ash_core::config::{register_scope_policies, clear_scope_policies};
144///
145/// clear_scope_policies();
146/// let mut policies = HashMap::new();
147/// policies.insert("POST|/api/transfer|", vec!["amount", "recipient"]);
148/// policies.insert("POST|/api/payment|", vec!["amount", "card_last4"]);
149/// register_scope_policies(&policies);
150/// ```
151pub fn register_scope_policies(policies_map: &HashMap<&str, Vec<&str>>) {
152    let mut registry = GLOBAL_REGISTRY.write().unwrap();
153    registry.register_many(policies_map);
154}
155
156/// Get the scope policy for a binding.
157///
158/// Returns empty vector if no policy is defined (full payload protection).
159///
160/// # Arguments
161///
162/// * `binding` - The normalized binding string
163///
164/// # Returns
165///
166/// The fields that must be protected
167///
168/// # Example
169///
170/// ```rust
171/// use ash_core::config::{register_scope_policy, get_scope_policy, clear_scope_policies};
172///
173/// clear_scope_policies();
174/// register_scope_policy("POST|/api/transfer|", &["amount", "recipient"]);
175///
176/// let scope = get_scope_policy("POST|/api/transfer|");
177/// assert_eq!(scope, vec!["amount", "recipient"]);
178///
179/// let no_scope = get_scope_policy("GET|/api/users|");
180/// assert!(no_scope.is_empty());
181/// ```
182pub fn get_scope_policy(binding: &str) -> Vec<String> {
183    let registry = GLOBAL_REGISTRY.read().unwrap();
184    registry.get(binding)
185}
186
187/// Check if a binding has a scope policy defined.
188///
189/// # Arguments
190///
191/// * `binding` - The normalized binding string
192///
193/// # Returns
194///
195/// True if a policy exists
196pub fn has_scope_policy(binding: &str) -> bool {
197    let registry = GLOBAL_REGISTRY.read().unwrap();
198    registry.has(binding)
199}
200
201/// Get all registered policies.
202///
203/// # Returns
204///
205/// All registered scope policies
206pub fn get_all_scope_policies() -> HashMap<String, Vec<String>> {
207    let registry = GLOBAL_REGISTRY.read().unwrap();
208    registry.get_all()
209}
210
211/// Clear all registered policies.
212///
213/// Useful for testing.
214pub fn clear_scope_policies() {
215    let mut registry = GLOBAL_REGISTRY.write().unwrap();
216    registry.clear();
217}
218
219/// Check if a binding matches a pattern with wildcards.
220///
221/// Supports:
222/// - `<param>` for Flask-style route parameters
223/// - `:param` for Express-style route parameters
224/// - `{param}` for Laravel/OpenAPI-style route parameters
225/// - `*` for single path segment wildcard
226/// - `**` for multi-segment wildcard
227fn matches_pattern(binding: &str, pattern: &str) -> bool {
228    // If no wildcards or params, must be exact match
229    if !pattern.contains('*')
230        && !pattern.contains('<')
231        && !pattern.contains(':')
232        && !pattern.contains('{')
233    {
234        return binding == pattern;
235    }
236
237    // Convert pattern to regex
238    let mut regex_str = regex::escape(pattern);
239
240    // Replace ** first (multi-segment)
241    regex_str = regex_str.replace(r"\*\*", ".*");
242
243    // Replace * (single segment - not containing | or /)
244    regex_str = regex_str.replace(r"\*", "[^|/]*");
245
246    // Replace <param> (Flask-style route params)
247    // Note: regex::escape does NOT escape < and >, so we match them directly
248    let flask_re = Regex::new(r"<[a-zA-Z_][a-zA-Z0-9_]*>").unwrap();
249    regex_str = flask_re.replace_all(&regex_str, "[^|/]+").to_string();
250
251    // Replace :param (Express-style route params)
252    let express_re = Regex::new(r":[a-zA-Z_][a-zA-Z0-9_]*").unwrap();
253    regex_str = express_re.replace_all(&regex_str, "[^|/]+").to_string();
254
255    // Replace {param} (Laravel/OpenAPI-style route params)
256    // Note: { and } are escaped by regex::escape to \{ and \}, so we match \\{ and \\}
257    // Using character class [{}] to avoid regex quantifier interpretation
258    let laravel_re = Regex::new(r"\\[{][a-zA-Z_][a-zA-Z0-9_]*\\[}]").unwrap();
259    regex_str = laravel_re.replace_all(&regex_str, "[^|/]+").to_string();
260
261    // Match against the binding
262    if let Ok(re) = Regex::new(&format!("^{}$", regex_str)) {
263        re.is_match(binding)
264    } else {
265        false
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    // Tests using isolated ScopePolicyRegistry instances - can run in parallel
274
275    #[test]
276    fn test_registry_register_and_get() {
277        let mut registry = ScopePolicyRegistry::new();
278        registry.register("POST|/api/transfer|", &["amount", "recipient"]);
279
280        let scope = registry.get("POST|/api/transfer|");
281        assert_eq!(scope, vec!["amount", "recipient"]);
282    }
283
284    #[test]
285    fn test_registry_get_no_match() {
286        let registry = ScopePolicyRegistry::new();
287
288        let scope = registry.get("GET|/api/users|");
289        assert!(scope.is_empty());
290    }
291
292    #[test]
293    fn test_registry_has() {
294        let mut registry = ScopePolicyRegistry::new();
295        registry.register("POST|/api/transfer|", &["amount"]);
296
297        assert!(registry.has("POST|/api/transfer|"));
298        assert!(!registry.has("GET|/api/users|"));
299    }
300
301    #[test]
302    fn test_registry_pattern_matching_flask_style() {
303        let mut registry = ScopePolicyRegistry::new();
304        registry.register("PUT|/api/users/<id>|", &["role", "permissions"]);
305
306        let scope = registry.get("PUT|/api/users/123|");
307        assert_eq!(scope, vec!["role", "permissions"]);
308    }
309
310    #[test]
311    fn test_registry_pattern_matching_express_style() {
312        let mut registry = ScopePolicyRegistry::new();
313        registry.register("PUT|/api/users/:id|", &["role"]);
314
315        let scope = registry.get("PUT|/api/users/456|");
316        assert_eq!(scope, vec!["role"]);
317    }
318
319    #[test]
320    fn test_registry_pattern_matching_laravel_style() {
321        let mut registry = ScopePolicyRegistry::new();
322        registry.register("PUT|/api/users/{id}|", &["email"]);
323
324        let scope = registry.get("PUT|/api/users/789|");
325        assert_eq!(scope, vec!["email"]);
326    }
327
328    #[test]
329    fn test_registry_pattern_matching_wildcard() {
330        let mut registry = ScopePolicyRegistry::new();
331        registry.register("POST|/api/*/transfer|", &["amount"]);
332
333        let scope = registry.get("POST|/api/v1/transfer|");
334        assert_eq!(scope, vec!["amount"]);
335    }
336
337    #[test]
338    fn test_registry_pattern_matching_double_wildcard() {
339        let mut registry = ScopePolicyRegistry::new();
340        registry.register("POST|/api/**/transfer|", &["amount"]);
341
342        let scope = registry.get("POST|/api/v1/users/transfer|");
343        assert_eq!(scope, vec!["amount"]);
344    }
345
346    #[test]
347    fn test_registry_clear() {
348        let mut registry = ScopePolicyRegistry::new();
349        registry.register("POST|/api/transfer|", &["amount"]);
350
351        assert!(registry.has("POST|/api/transfer|"));
352
353        registry.clear();
354
355        assert!(!registry.has("POST|/api/transfer|"));
356    }
357
358    #[test]
359    fn test_registry_register_many() {
360        let mut registry = ScopePolicyRegistry::new();
361        let mut policies = HashMap::new();
362        policies.insert("POST|/api/transfer|", vec!["amount"]);
363        policies.insert("POST|/api/payment|", vec!["card"]);
364
365        registry.register_many(&policies);
366
367        assert!(registry.has("POST|/api/transfer|"));
368        assert!(registry.has("POST|/api/payment|"));
369    }
370
371    #[test]
372    fn test_registry_get_all() {
373        let mut registry = ScopePolicyRegistry::new();
374        registry.register("POST|/api/transfer|", &["amount"]);
375        registry.register("POST|/api/payment|", &["card"]);
376
377        let all = registry.get_all();
378        assert_eq!(all.len(), 2);
379    }
380}