Skip to main content

rocketmq_auth/authorization/strategy/
stateful_authorization_strategy.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Stateful authorization strategy with caching.
16//!
17//! This strategy caches authorization decisions for connection-based requests,
18//! providing better performance for repeated authorization checks on the same
19//! channel/connection.
20
21use std::any::Any;
22use std::collections::HashMap;
23use std::sync::atomic::AtomicU64;
24use std::sync::atomic::AtomicUsize;
25use std::sync::atomic::Ordering;
26use std::sync::Arc;
27use std::sync::RwLock;
28use std::sync::RwLockWriteGuard;
29use std::time::Duration;
30use std::time::Instant;
31
32use rocketmq_security_api::AuthorizationDecision;
33#[cfg(test)]
34use rocketmq_security_api::AuthorizationDenial;
35use tracing::debug;
36
37use crate::authorization::context::default_authorization_context::DefaultAuthorizationContext;
38use crate::authorization::strategy::abstract_authorization_strategy::AbstractAuthorizationStrategy;
39use crate::authorization::strategy::abstract_authorization_strategy::AuthorizationStrategy;
40use crate::authorization::strategy::evaluate_base_authorization;
41use crate::authorization::strategy::AuthorizationFuture;
42use crate::config::AuthConfig;
43use crate::AuthMetrics;
44#[cfg(test)]
45use crate::AuthServiceError;
46use crate::AuthServiceResult;
47
48type AuthCache = HashMap<String, CachedAuthResult>;
49
50/// Cached authorization result.
51#[derive(Clone, Debug)]
52struct CachedAuthResult {
53    /// The final policy decision. Operational failures are never cached.
54    decision: AuthorizationDecision,
55    /// Timestamp when this entry was cached
56    cached_at: Instant,
57}
58
59impl CachedAuthResult {
60    fn new(decision: AuthorizationDecision) -> Self {
61        Self {
62            decision,
63            cached_at: Instant::now(),
64        }
65    }
66
67    fn is_expired(&self, ttl: Duration) -> bool {
68        self.cached_at.elapsed() > ttl
69    }
70}
71
72fn cacheable_decision(
73    result: &AuthServiceResult<AuthorizationDecision>,
74    cache_negative_result: bool,
75) -> Option<AuthorizationDecision> {
76    match result {
77        Ok(AuthorizationDecision::Allow) => Some(AuthorizationDecision::Allow),
78        Ok(decision @ AuthorizationDecision::Deny(_)) if cache_negative_result => Some(*decision),
79        Ok(AuthorizationDecision::Deny(_)) | Err(_) => None,
80    }
81}
82
83/// Stateful authorization strategy with caching.
84///
85/// This strategy caches authorization decisions based on channel ID, reducing
86/// the overhead of repeated authorization checks for the same connection.
87///
88/// # Cache Key
89///
90/// The cache key is built from:
91/// - Channel ID
92/// - Subject key
93/// - Resource key
94/// - Actions (comma-separated)
95/// - Source IP
96///
97/// Format: `{channel_id}#{subject_key}#{resource_key}#{actions}#{source_ip}`
98///
99/// # Cache Behavior
100///
101/// - **Cache Hit**: Returns cached result immediately
102/// - **Cache Miss**: Evaluates authorization and caches the result
103/// - **TTL**: Cached entries expire after configured duration
104/// - **No Channel ID**: Bypasses cache and evaluates directly
105///
106/// # Use Cases
107///
108/// - Connection-based protocols (e.g., long-lived TCP connections)
109/// - High-throughput scenarios with repeated authorization checks
110/// - Systems where authorization policies are relatively stable
111///
112/// # Trade-offs
113///
114/// **Advantages:**
115/// - Significantly reduced latency for cached requests
116/// - Lower load on authorization provider
117/// - Better performance for connection-based protocols
118///
119/// **Disadvantages:**
120/// - Potential delay in policy updates (up to TTL)
121/// - Higher memory footprint
122/// - Cache consistency considerations
123///
124/// # Examples
125///
126/// ```rust,ignore
127/// use rocketmq_auth::StatefulAuthorizationStrategy;
128/// use rocketmq_auth::AuthConfig;
129///
130/// let config = AuthConfig::default();
131/// let strategy = StatefulAuthorizationStrategy::new(config, None)?;
132///
133/// // First evaluation: queries provider
134/// let context = DefaultAuthorizationContext::of(subject, resource, action, ip);
135/// context.set_channel_id("channel-123");
136/// match strategy.evaluate(&context).await? { // Cache miss
137///     AuthorizationDecision::Allow => proceed(),
138///     AuthorizationDecision::Deny(reason) => reject(reason),
139/// }
140///
141/// // Second evaluation: uses cache
142/// match strategy.evaluate(&context).await? { // Cache hit
143///     AuthorizationDecision::Allow => proceed(),
144///     AuthorizationDecision::Deny(reason) => reject(reason),
145/// }
146/// ```
147pub struct StatefulAuthorizationStrategy {
148    /// Base authorization strategy
149    base: AbstractAuthorizationStrategy,
150
151    /// Authorization cache (key -> result)
152    auth_cache: Arc<RwLock<AuthCache>>,
153
154    /// Cache TTL
155    cache_ttl: Duration,
156
157    /// Maximum cache size
158    cache_max_size: usize,
159
160    /// Request counter for periodic cleanup
161    request_counter: AtomicUsize,
162
163    /// Shared ACL snapshot generation. A successful ACL reload advances this value.
164    acl_generation: Arc<AtomicU64>,
165
166    /// Last generation observed by this strategy, used to drop old entries promptly.
167    last_seen_acl_generation: AtomicU64,
168    cache_negative_result: bool,
169    metrics: AuthMetrics,
170}
171
172impl StatefulAuthorizationStrategy {
173    /// Creates a new stateful authorization strategy with caching.
174    ///
175    /// # Arguments
176    ///
177    /// * `auth_config` - Authorization configuration
178    /// * `metadata_service` - Optional metadata service for provider initialization
179    ///
180    /// # Returns
181    ///
182    /// A new `StatefulAuthorizationStrategy` instance.
183    ///
184    /// # Examples
185    ///
186    /// ```rust,ignore
187    /// let config = AuthConfig::default();
188    /// let strategy = StatefulAuthorizationStrategy::new(config, None)?;
189    /// ```
190    pub fn new(
191        auth_config: AuthConfig,
192        metadata_service: Option<Box<dyn Any + Send + Sync>>,
193    ) -> AuthServiceResult<Self> {
194        let acl_generation = metadata_service
195            .as_ref()
196            .and_then(|service| service.downcast_ref::<crate::ProviderRegistry>())
197            .map(crate::ProviderRegistry::acl_generation_counter)
198            .unwrap_or_default();
199        Self::new_with_acl_generation(auth_config, metadata_service, acl_generation)
200    }
201
202    /// Creates a new stateful authorization strategy bound to a shared ACL generation counter.
203    pub fn new_with_acl_generation(
204        auth_config: AuthConfig,
205        metadata_service: Option<Box<dyn Any + Send + Sync>>,
206        acl_generation: Arc<AtomicU64>,
207    ) -> AuthServiceResult<Self> {
208        Self::new_with_acl_generation_and_metrics(auth_config, metadata_service, acl_generation, AuthMetrics::default())
209    }
210
211    pub fn new_with_acl_generation_and_metrics(
212        auth_config: AuthConfig,
213        metadata_service: Option<Box<dyn Any + Send + Sync>>,
214        acl_generation: Arc<AtomicU64>,
215        metrics: AuthMetrics,
216    ) -> AuthServiceResult<Self> {
217        let cache_ttl = Duration::from_secs(auth_config.stateful_authorization_cache_expired_second as u64);
218        let cache_max_size = auth_config.stateful_authorization_cache_max_num as usize;
219        let cache_negative_result = auth_config.stateful_authorization_cache_negative_enable;
220
221        let base = AbstractAuthorizationStrategy::new(auth_config, metadata_service)?;
222        let initial_generation = acl_generation.load(Ordering::Acquire);
223
224        debug!(
225            "StatefulAuthorizationStrategy initialized with TTL: {:?}, max size: {}",
226            cache_ttl, cache_max_size
227        );
228
229        Ok(Self {
230            base,
231            auth_cache: Arc::new(RwLock::new(HashMap::new())),
232            cache_ttl,
233            cache_max_size,
234            request_counter: AtomicUsize::new(0),
235            acl_generation,
236            last_seen_acl_generation: AtomicU64::new(initial_generation),
237            cache_negative_result,
238            metrics,
239        })
240    }
241
242    /// Builds a cache key from the authorization context.
243    ///
244    /// Format: `{generation}#{channel_id}#{subject_key}#{resource_key}#{actions}#{source_ip}`
245    fn build_key(&self, context: &DefaultAuthorizationContext) -> String {
246        let generation = self.refresh_acl_generation();
247        let channel_id = context.channel_id().unwrap_or("");
248        let subject_key = context.subject().map(|s| s.subject_key()).unwrap_or_default();
249        let resource_key = context.resource().map(|r| format!("{:?}", r)).unwrap_or_default();
250        let actions = context
251            .actions()
252            .iter()
253            .map(|a| format!("{:?}", a))
254            .collect::<Vec<_>>()
255            .join(",");
256        let source_ip = context.source_ip().unwrap_or("");
257
258        format!(
259            "{}#{}#{}#{}#{}#{}",
260            generation, channel_id, subject_key, resource_key, actions, source_ip
261        )
262    }
263
264    fn refresh_acl_generation(&self) -> u64 {
265        let current = self.acl_generation.load(Ordering::Acquire);
266        let previous = self.last_seen_acl_generation.load(Ordering::Acquire);
267        if previous != current
268            && self
269                .last_seen_acl_generation
270                .compare_exchange(previous, current, Ordering::AcqRel, Ordering::Acquire)
271                .is_ok()
272        {
273            self.clear_cache();
274            self.metrics.record_cache_invalidation();
275        }
276        current
277    }
278
279    fn recover_poisoned_cache(&self) {
280        let mut cache = match self.auth_cache.write() {
281            Ok(cache) => cache,
282            Err(poisoned) => poisoned.into_inner(),
283        };
284        cache.clear();
285        self.auth_cache.clear_poison();
286        debug!("Authorization cache lock poisoned; cache cleared");
287    }
288
289    fn write_cache_recovering(&self) -> RwLockWriteGuard<'_, AuthCache> {
290        match self.auth_cache.write() {
291            Ok(cache) => cache,
292            Err(poisoned) => {
293                let mut cache = poisoned.into_inner();
294                cache.clear();
295                self.auth_cache.clear_poison();
296                debug!("Authorization cache write lock poisoned; cache cleared before reuse");
297                cache
298            }
299        }
300    }
301
302    /// Evicts expired entries from the cache.
303    fn evict_expired(&self) {
304        let mut cache = self.write_cache_recovering();
305        let before_size = cache.len();
306
307        cache.retain(|_, result| !result.is_expired(self.cache_ttl));
308
309        let evicted = before_size - cache.len();
310        if evicted > 0 {
311            debug!("Evicted {} expired cache entries", evicted);
312        }
313    }
314
315    /// Evicts oldest entries if cache is over capacity.
316    fn evict_if_full(&self) {
317        let mut cache = self.write_cache_recovering();
318
319        if cache.len() >= self.cache_max_size {
320            // Simple eviction: remove first 10% of entries
321            let to_remove = (self.cache_max_size / 10).max(1);
322            let keys_to_remove: Vec<String> = cache.keys().take(to_remove).cloned().collect();
323
324            for key in keys_to_remove {
325                cache.remove(&key);
326            }
327
328            debug!("Evicted {} entries to maintain cache size", to_remove);
329        }
330    }
331
332    /// Gets a reference to the base strategy.
333    pub fn base(&self) -> &AbstractAuthorizationStrategy {
334        &self.base
335    }
336
337    /// Gets the current cache size.
338    pub fn cache_size(&self) -> usize {
339        match self.auth_cache.read() {
340            Ok(cache) => cache.len(),
341            Err(poisoned) => {
342                drop(poisoned);
343                self.recover_poisoned_cache();
344                0
345            }
346        }
347    }
348
349    /// Clears the entire cache.
350    pub fn clear_cache(&self) {
351        let mut cache = self.write_cache_recovering();
352        cache.clear();
353        debug!("Authorization cache cleared");
354    }
355}
356
357impl AuthorizationStrategy for StatefulAuthorizationStrategy {
358    /// Evaluates authorization with caching support.
359    ///
360    /// If the context has a channel ID, the result is cached. Subsequent
361    /// requests with the same cache key will use the cached result until
362    /// it expires.
363    ///
364    /// # Arguments
365    ///
366    /// * `context` - The authorization context to evaluate
367    ///
368    /// # Returns
369    ///
370    /// * `Ok(AuthorizationDecision)` for a final allow or deny
371    /// * `Err(AuthServiceError)` if evaluation cannot make a decision
372    ///
373    /// # Examples
374    ///
375    /// ```rust,ignore
376    /// let strategy = StatefulAuthorizationStrategy::new(config, None)?;
377    /// let mut context = DefaultAuthorizationContext::of(subject, resource, action, ip);
378    /// context.set_channel_id("channel-123");
379    /// match strategy.evaluate(&context).await? {
380    ///     AuthorizationDecision::Allow => proceed(),
381    ///     AuthorizationDecision::Deny(reason) => reject(reason),
382    /// }
383    /// ```
384    fn evaluate<'a>(&'a self, context: &'a DefaultAuthorizationContext) -> AuthorizationFuture<'a> {
385        Box::pin(async move {
386            let _operation = self.base.enter_operation()?;
387            // If no channel ID, bypass cache
388            if context.channel_id().is_none_or(str::is_empty) {
389                debug!("No channel ID, bypassing cache");
390                return evaluate_base_authorization(&self.base, context).await;
391            }
392
393            let cache_key = self.build_key(context);
394
395            // Check cache
396            {
397                match self.auth_cache.read() {
398                    Ok(cache) => {
399                        if let Some(cached_result) = cache.get(&cache_key) {
400                            if !cached_result.is_expired(self.cache_ttl) {
401                                debug!("Authorization cache hit");
402                                self.metrics.record_cache_hit();
403                                return Ok(cached_result.decision);
404                            } else {
405                                debug!("Authorization cache entry expired");
406                            }
407                        }
408                    }
409                    Err(poisoned) => {
410                        drop(poisoned);
411                        self.recover_poisoned_cache();
412                        debug!("Authorization cache unavailable after poison recovery");
413                    }
414                }
415            }
416
417            // Cache miss - evaluate and cache result
418            debug!("Authorization cache miss");
419            self.metrics.record_cache_miss();
420
421            let result = evaluate_base_authorization(&self.base, context).await;
422
423            // Cache granted results by default. Denied results are cached only when
424            // explicitly enabled because ACL updates must take effect conservatively.
425            if let Some(decision) = cacheable_decision(&result, self.cache_negative_result) {
426                self.evict_if_full();
427
428                let mut cache = self.write_cache_recovering();
429                cache.insert(cache_key.clone(), CachedAuthResult::new(decision));
430                debug!("Authorization decision cached");
431            }
432
433            // Periodic cleanup every 100 requests
434            let counter = self.request_counter.fetch_add(1, Ordering::Relaxed);
435            if counter.is_multiple_of(100) {
436                self.evict_expired();
437            }
438
439            result
440        })
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use cheetah_string::CheetahString;
447    use rocketmq_security_api::Action;
448
449    use super::*;
450    use crate::authentication::enums::subject_type::SubjectType;
451    use crate::authorization::model::resource::Resource;
452
453    fn create_test_config() -> AuthConfig {
454        AuthConfig {
455            config_name: CheetahString::from("test"),
456            cluster_name: CheetahString::from("test-cluster"),
457            auth_config_path: CheetahString::from("/tmp/auth"),
458            acl_file: CheetahString::new(),
459            acl_file_watch_enabled: false,
460            acl_file_watch_interval_millis: 5_000,
461            authentication_enabled: false,
462            authentication_provider: CheetahString::new(),
463            authentication_metadata_provider: CheetahString::new(),
464            authentication_strategy: CheetahString::new(),
465            authentication_whitelist: CheetahString::new(),
466            init_authentication_user: CheetahString::new(),
467            inner_client_authentication_credentials: CheetahString::new(),
468            signature_algorithm: Default::default(),
469            request_timestamp_expired_millis: 0,
470            authorization_enabled: true,
471            authorization_provider: CheetahString::new(),
472            authorization_metadata_provider: CheetahString::new(),
473            authorization_strategy: CheetahString::new(),
474            authorization_whitelist: CheetahString::new(),
475            migrate_auth_from_v1_enabled: false,
476            user_cache_max_num: 1000,
477            user_cache_expired_second: 300,
478            user_cache_refresh_second: 60,
479            acl_cache_max_num: 1000,
480            acl_cache_expired_second: 300,
481            acl_cache_refresh_second: 60,
482            stateful_authentication_cache_max_num: 1000,
483            stateful_authentication_cache_expired_second: 300,
484            stateful_authorization_cache_max_num: 100,
485            stateful_authorization_cache_expired_second: 60,
486            stateful_authorization_cache_negative_enable: false,
487            ..AuthConfig::default()
488        }
489    }
490
491    #[test]
492    fn test_stateful_strategy_creation() {
493        let config = create_test_config();
494        let strategy = StatefulAuthorizationStrategy::new(config, None);
495        assert!(strategy.is_ok());
496    }
497
498    #[test]
499    fn test_cache_key_building() {
500        let config = create_test_config();
501        let strategy = StatefulAuthorizationStrategy::new(config, None).unwrap();
502
503        let mut context = DefaultAuthorizationContext::default();
504        context.set_channel_id("ch-123".to_string());
505        context.set_source_ip("192.168.0.1".to_string());
506
507        let key = strategy.build_key(&context);
508        assert!(key.starts_with("0#"));
509        assert!(key.contains("ch-123"));
510        assert!(key.contains("192.168.0.1"));
511    }
512
513    #[test]
514    fn test_cache_key_includes_acl_generation() {
515        let config = create_test_config();
516        let acl_generation = Arc::new(AtomicU64::new(11));
517        let strategy =
518            StatefulAuthorizationStrategy::new_with_acl_generation(config, None, acl_generation.clone()).unwrap();
519
520        let mut context = DefaultAuthorizationContext::default();
521        context.set_channel_id("ch-123".to_string());
522        context.set_source_ip("192.168.0.1".to_string());
523
524        let key = strategy.build_key(&context);
525        assert!(key.starts_with("11#ch-123#"));
526
527        acl_generation.store(12, Ordering::Release);
528        let key = strategy.build_key(&context);
529        assert!(key.starts_with("12#ch-123#"));
530    }
531
532    #[tokio::test]
533    async fn test_no_channel_id_bypasses_cache() {
534        let mut config = create_test_config();
535        config.authorization_enabled = false;
536
537        let strategy = StatefulAuthorizationStrategy::new(config, None).unwrap();
538        let context = DefaultAuthorizationContext::default();
539
540        let result = strategy.evaluate(&context).await;
541        assert!(result.is_ok());
542        assert_eq!(strategy.cache_size(), 0); // Nothing cached
543    }
544
545    #[test]
546    fn test_clear_cache() {
547        let config = create_test_config();
548        let strategy = StatefulAuthorizationStrategy::new(config, None).unwrap();
549
550        strategy.clear_cache();
551        assert_eq!(strategy.cache_size(), 0);
552    }
553
554    #[tokio::test]
555    async fn test_acl_generation_change_invalidates_cached_authorization_result() {
556        let mut config = create_test_config();
557        config.authorization_enabled = false;
558        let acl_generation = Arc::new(AtomicU64::new(0));
559        let strategy =
560            StatefulAuthorizationStrategy::new_with_acl_generation(config, None, acl_generation.clone()).unwrap();
561        let mut context = DefaultAuthorizationContext::default();
562        context.set_channel_id("ch-123".to_string());
563        context.set_source_ip("192.168.0.1".to_string());
564
565        assert_eq!(strategy.evaluate(&context).await.unwrap(), AuthorizationDecision::Allow);
566        assert_eq!(strategy.cache_size(), 1);
567
568        acl_generation.fetch_add(1, Ordering::AcqRel);
569        assert_eq!(strategy.evaluate(&context).await.unwrap(), AuthorizationDecision::Allow);
570        assert_eq!(strategy.cache_size(), 1);
571
572        let cache = strategy.auth_cache.read().unwrap();
573        assert!(cache.keys().all(|key| key.starts_with("1#")));
574    }
575
576    #[tokio::test]
577    async fn poisoned_authorization_cache_is_cleared_and_evaluation_continues() {
578        let mut config = create_test_config();
579        config.authorization_enabled = false;
580        let strategy = StatefulAuthorizationStrategy::new(config, None).unwrap();
581        let cache = strategy.auth_cache.clone();
582
583        let poison_result = std::thread::spawn(move || {
584            let _guard = cache.write().unwrap();
585            panic!("poison authorization cache for test");
586        })
587        .join();
588        assert!(poison_result.is_err());
589
590        let mut context = DefaultAuthorizationContext::default();
591        context.set_channel_id("ch-poison".to_string());
592
593        assert_eq!(strategy.evaluate(&context).await.unwrap(), AuthorizationDecision::Allow);
594        assert_eq!(strategy.cache_size(), 1);
595        assert!(strategy.auth_cache.read().is_ok());
596    }
597
598    #[tokio::test(flavor = "current_thread")]
599    async fn evaluate_cache_miss_inside_current_thread_runtime_without_nested_block_on_panic() {
600        let mut config = create_test_config();
601        config.authorization_enabled = false;
602        let strategy = StatefulAuthorizationStrategy::new(config, None).unwrap();
603        let mut context = DefaultAuthorizationContext::default();
604        context.set_channel_id("ch-current-thread".to_string());
605        assert_eq!(strategy.evaluate(&context).await.unwrap(), AuthorizationDecision::Allow);
606
607        assert_eq!(strategy.cache_size(), 1);
608    }
609
610    #[tokio::test]
611    async fn denied_authorization_is_not_cached_by_default() {
612        let config = create_test_config();
613        let strategy = StatefulAuthorizationStrategy::new(config, None).unwrap();
614        let mut context = DefaultAuthorizationContext::of(
615            "alice",
616            SubjectType::User,
617            Resource::of_topic("TopicA"),
618            Action::Pub,
619            "127.0.0.1",
620        );
621        context.set_channel_id("ch-denied-default".to_string());
622
623        assert_eq!(
624            strategy.evaluate(&context).await.unwrap(),
625            AuthorizationDecision::Deny(AuthorizationDenial::SubjectUnknown)
626        );
627        assert_eq!(strategy.cache_size(), 0);
628    }
629
630    #[tokio::test]
631    async fn denied_authorization_can_be_cached_when_enabled() {
632        let mut config = create_test_config();
633        config.stateful_authorization_cache_negative_enable = true;
634        let strategy = StatefulAuthorizationStrategy::new(config, None).unwrap();
635        let mut context = DefaultAuthorizationContext::of(
636            "alice",
637            SubjectType::User,
638            Resource::of_topic("TopicA"),
639            Action::Pub,
640            "127.0.0.1",
641        );
642        context.set_channel_id("ch-denied-cached".to_string());
643
644        assert_eq!(
645            strategy.evaluate(&context).await.unwrap(),
646            AuthorizationDecision::Deny(AuthorizationDenial::SubjectUnknown)
647        );
648        assert_eq!(strategy.cache_size(), 1);
649    }
650
651    #[test]
652    fn operational_errors_are_never_cacheable() {
653        let result = Err(AuthServiceError::invalid_context("broken context".to_owned()));
654
655        assert_eq!(cacheable_decision(&result, false), None);
656        assert_eq!(cacheable_decision(&result, true), None);
657    }
658}