Skip to main content

mecha10_core/
auth.rs

1//! Authorization and Access Control
2//!
3//! Provides role-based access control (RBAC) for pub/sub topics.
4//! Enables controlling which nodes can publish/subscribe to specific topics.
5//!
6//! # Architecture
7//!
8//! - Simple role-based permissions
9//! - Wildcard pattern matching for topics
10//! - Policies stored in Redis
11//! - Optional enforcement (can be disabled for development)
12//!
13//! # Example
14//!
15//! ```rust
16//! use mecha10_core::prelude::*;
17//! use mecha10_core::auth::{AuthExt, Policy, Permission};
18//!
19//! # async fn example(ctx: &Context) -> Result<()> {
20//! // Define a policy
21//! let policy = Policy::new("camera_driver")
22//!     .allow(Permission::Publish, "/sensors/camera/*")
23//!     .allow(Permission::Subscribe, "/control/camera/*");
24//!
25//! ctx.register_policy(&policy).await?;
26//!
27//! // Assign role to node
28//! ctx.assign_role("camera-1", "camera_driver").await?;
29//!
30//! // Check permission (automatically done in pub/sub)
31//! let allowed = ctx.check_permission(
32//!     "camera-1",
33//!     Permission::Publish,
34//!     "/sensors/camera/rgb"
35//! ).await?;
36//!
37//! # Ok(())
38//! # }
39//! ```
40
41use crate::context::Context;
42use crate::enum_str_method;
43use crate::error::{Mecha10Error, Result};
44use serde::{Deserialize, Serialize};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Permission {
49    Publish,
50    Subscribe,
51}
52
53enum_str_method!(
54    #[allow(dead_code)]
55    fn as_str(Permission) -> &str {
56        Publish => "publish",
57        Subscribe => "subscribe",
58    }
59);
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Rule {
63    pub permission: Permission,
64
65    pub topic_pattern: String,
66}
67
68impl Rule {
69    /// Create a new rule
70    pub fn new(permission: Permission, topic_pattern: impl Into<String>) -> Self {
71        Self {
72            permission,
73            topic_pattern: topic_pattern.into(),
74        }
75    }
76
77    /// Check if a topic matches this rule's pattern
78    pub fn matches(&self, topic: &str) -> bool {
79        matches_pattern(&self.topic_pattern, topic)
80    }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct Policy {
85    pub role: String,
86
87    pub rules: Vec<Rule>,
88
89    #[serde(default)]
90    pub description: String,
91}
92
93impl Policy {
94    /// Create a new policy for a role
95    pub fn new(role: impl Into<String>) -> Self {
96        Self {
97            role: role.into(),
98            rules: Vec::new(),
99            description: String::new(),
100        }
101    }
102
103    /// Set description
104    pub fn with_description(mut self, description: impl Into<String>) -> Self {
105        self.description = description.into();
106        self
107    }
108
109    /// Add a permission rule
110    pub fn allow(mut self, permission: Permission, topic_pattern: impl Into<String>) -> Self {
111        self.rules.push(Rule::new(permission, topic_pattern));
112        self
113    }
114
115    /// Add publish permission
116    pub fn allow_publish(self, topic_pattern: impl Into<String>) -> Self {
117        self.allow(Permission::Publish, topic_pattern)
118    }
119
120    /// Add subscribe permission
121    pub fn allow_subscribe(self, topic_pattern: impl Into<String>) -> Self {
122        self.allow(Permission::Subscribe, topic_pattern)
123    }
124
125    /// Check if this policy allows a specific action
126    pub fn allows(&self, permission: Permission, topic: &str) -> bool {
127        self.rules
128            .iter()
129            .any(|rule| rule.permission == permission && rule.matches(topic))
130    }
131
132    /// Get Redis key for this policy
133    fn redis_key(&self) -> String {
134        format!("mecha10:auth:policies:{}", self.role)
135    }
136
137    /// Get Redis key for policies list
138    fn redis_list_key() -> String {
139        "mecha10:auth:policies:list".to_string()
140    }
141
142    /// Get Redis key for node role assignment
143    fn redis_role_key(node_id: &str) -> String {
144        format!("mecha10:auth:roles:{}", node_id)
145    }
146}
147
148pub trait AuthExt {
149    /// Register a policy
150    ///
151    /// # Arguments
152    ///
153    /// * `policy` - Policy to register
154    ///
155    /// # Example
156    ///
157    /// ```rust
158    /// use mecha10_core::prelude::*;
159    /// use mecha10_core::auth::{AuthExt, Policy, Permission};
160    ///
161    /// # async fn example(ctx: &Context) -> Result<()> {
162    /// let policy = Policy::new("camera_driver")
163    ///     .allow_publish("/sensors/camera/*")
164    ///     .allow_subscribe("/control/camera/*");
165    ///
166    /// ctx.register_policy(&policy).await?;
167    /// # Ok(())
168    /// # }
169    /// ```
170    fn register_policy(&self, policy: &Policy) -> impl std::future::Future<Output = Result<()>> + Send;
171
172    /// Get a policy by role name
173    ///
174    /// # Arguments
175    ///
176    /// * `role` - Role name
177    fn get_policy(&self, role: &str) -> impl std::future::Future<Output = Result<Option<Policy>>> + Send;
178
179    /// List all registered policies
180    fn list_policies(&self) -> impl std::future::Future<Output = Result<Vec<Policy>>> + Send;
181
182    /// Assign a role to a node
183    ///
184    /// # Arguments
185    ///
186    /// * `node_id` - Node identifier
187    /// * `role` - Role to assign
188    fn assign_role(&self, node_id: &str, role: &str) -> impl std::future::Future<Output = Result<()>> + Send;
189
190    /// Get the role assigned to a node
191    ///
192    /// # Arguments
193    ///
194    /// * `node_id` - Node identifier
195    fn get_role(&self, node_id: &str) -> impl std::future::Future<Output = Result<Option<String>>> + Send;
196
197    /// Check if a node has permission to perform an action on a topic
198    ///
199    /// # Arguments
200    ///
201    /// * `node_id` - Node identifier
202    /// * `permission` - Permission type (Publish/Subscribe)
203    /// * `topic` - Topic path
204    ///
205    /// # Returns
206    ///
207    /// `true` if permission is granted, `false` otherwise
208    fn check_permission(
209        &self,
210        node_id: &str,
211        permission: Permission,
212        topic: &str,
213    ) -> impl std::future::Future<Output = Result<bool>> + Send;
214
215    /// Delete a policy
216    ///
217    /// # Arguments
218    ///
219    /// * `role` - Role name
220    fn delete_policy(&self, role: &str) -> impl std::future::Future<Output = Result<()>> + Send;
221
222    /// Remove role assignment from a node
223    ///
224    /// # Arguments
225    ///
226    /// * `node_id` - Node identifier
227    fn unassign_role(&self, node_id: &str) -> impl std::future::Future<Output = Result<()>> + Send;
228}
229
230impl AuthExt for Context {
231    async fn register_policy(&self, policy: &Policy) -> Result<()> {
232        #[cfg(feature = "messaging")]
233        {
234            use redis::AsyncCommands;
235
236            let mut conn = crate::redis_list_util::get_redis_connection().await?;
237
238            let policy_json = serde_json::to_string(&policy)
239                .map_err(|e| Mecha10Error::Other(format!("Failed to serialize policy: {}", e)))?;
240
241            let key = policy.redis_key();
242            conn.set::<_, _, ()>(&key, &policy_json)
243                .await
244                .map_err(|e| Mecha10Error::MessagingError {
245                    message: format!("Failed to register policy: {}", e),
246                    suggestion: "Check Redis connection".to_string(),
247                })?;
248
249            let list_key = Policy::redis_list_key();
250            conn.sadd::<_, _, ()>(&list_key, &policy.role)
251                .await
252                .map_err(|e| Mecha10Error::MessagingError {
253                    message: format!("Failed to update policy list: {}", e),
254                    suggestion: "Check Redis connection".to_string(),
255                })?;
256
257            Ok(())
258        }
259
260        #[cfg(not(feature = "messaging"))]
261        {
262            Err(Mecha10Error::Other("Messaging feature not enabled".to_string()))
263        }
264    }
265
266    async fn get_policy(&self, role: &str) -> Result<Option<Policy>> {
267        #[cfg(feature = "messaging")]
268        {
269            use redis::AsyncCommands;
270
271            let mut conn = crate::redis_list_util::get_redis_connection().await?;
272
273            let key = format!("mecha10:auth:policies:{}", role);
274            let json: Option<String> = conn.get(&key).await.map_err(|e| Mecha10Error::MessagingError {
275                message: format!("Failed to get policy: {}", e),
276                suggestion: "Check Redis connection".to_string(),
277            })?;
278
279            if let Some(json) = json {
280                let policy = serde_json::from_str::<Policy>(&json)
281                    .map_err(|e| Mecha10Error::Other(format!("Failed to parse policy: {}", e)))?;
282                Ok(Some(policy))
283            } else {
284                Ok(None)
285            }
286        }
287
288        #[cfg(not(feature = "messaging"))]
289        {
290            Err(Mecha10Error::Other("Messaging feature not enabled".to_string()))
291        }
292    }
293
294    async fn list_policies(&self) -> Result<Vec<Policy>> {
295        #[cfg(feature = "messaging")]
296        {
297            crate::redis_list_util::collect_from_redis_set(&Policy::redis_list_key(), |role| async move {
298                self.get_policy(&role).await
299            })
300            .await
301        }
302
303        #[cfg(not(feature = "messaging"))]
304        {
305            Err(Mecha10Error::Other("Messaging feature not enabled".to_string()))
306        }
307    }
308
309    async fn assign_role(&self, node_id: &str, role: &str) -> Result<()> {
310        #[cfg(feature = "messaging")]
311        {
312            use redis::AsyncCommands;
313
314            let mut conn = crate::redis_list_util::get_redis_connection().await?;
315
316            let key = Policy::redis_role_key(node_id);
317            conn.set::<_, _, ()>(&key, role)
318                .await
319                .map_err(|e| Mecha10Error::MessagingError {
320                    message: format!("Failed to assign role: {}", e),
321                    suggestion: "Check Redis connection".to_string(),
322                })?;
323
324            Ok(())
325        }
326
327        #[cfg(not(feature = "messaging"))]
328        {
329            Err(Mecha10Error::Other("Messaging feature not enabled".to_string()))
330        }
331    }
332
333    async fn get_role(&self, node_id: &str) -> Result<Option<String>> {
334        #[cfg(feature = "messaging")]
335        {
336            let key = Policy::redis_role_key(node_id);
337            crate::redis_list_util::get_optional_from_redis(&key, "Failed to get role").await
338        }
339
340        #[cfg(not(feature = "messaging"))]
341        {
342            Err(Mecha10Error::Other("Messaging feature not enabled".to_string()))
343        }
344    }
345
346    async fn check_permission(&self, node_id: &str, permission: Permission, topic: &str) -> Result<bool> {
347        let role = match self.get_role(node_id).await? {
348            Some(role) => role,
349            None => return Ok(false),
350        };
351
352        let policy = match self.get_policy(&role).await? {
353            Some(policy) => policy,
354            None => return Ok(false),
355        };
356
357        Ok(policy.allows(permission, topic))
358    }
359
360    async fn delete_policy(&self, role: &str) -> Result<()> {
361        #[cfg(feature = "messaging")]
362        {
363            use redis::AsyncCommands;
364
365            let mut conn = crate::redis_list_util::get_redis_connection().await?;
366
367            let key = format!("mecha10:auth:policies:{}", role);
368            conn.del::<_, ()>(&key)
369                .await
370                .map_err(|e| Mecha10Error::MessagingError {
371                    message: format!("Failed to delete policy: {}", e),
372                    suggestion: "Check Redis connection".to_string(),
373                })?;
374
375            let list_key = Policy::redis_list_key();
376            conn.srem::<_, _, ()>(&list_key, role)
377                .await
378                .map_err(|e| Mecha10Error::MessagingError {
379                    message: format!("Failed to update policy list: {}", e),
380                    suggestion: "Check Redis connection".to_string(),
381                })?;
382
383            Ok(())
384        }
385
386        #[cfg(not(feature = "messaging"))]
387        {
388            Err(Mecha10Error::Other("Messaging feature not enabled".to_string()))
389        }
390    }
391
392    async fn unassign_role(&self, node_id: &str) -> Result<()> {
393        #[cfg(feature = "messaging")]
394        {
395            use redis::AsyncCommands;
396
397            let mut conn = crate::redis_list_util::get_redis_connection().await?;
398
399            let key = Policy::redis_role_key(node_id);
400            conn.del::<_, ()>(&key)
401                .await
402                .map_err(|e| Mecha10Error::MessagingError {
403                    message: format!("Failed to unassign role: {}", e),
404                    suggestion: "Check Redis connection".to_string(),
405                })?;
406
407            Ok(())
408        }
409
410        #[cfg(not(feature = "messaging"))]
411        {
412            Err(Mecha10Error::Other("Messaging feature not enabled".to_string()))
413        }
414    }
415}
416
417/// Check if a topic matches a pattern
418///
419/// Uses the same glob matching implementation as aggregated topics,
420/// supporting full glob syntax including *, **, ?, [abc], {a,b}, etc.
421///
422/// # Examples
423///
424/// ```
425/// # use mecha10_core::auth::matches_pattern;
426/// assert!(matches_pattern("/sensors/*", "/sensors/camera"));
427/// assert!(matches_pattern("/sensors/*/rgb", "/sensors/camera/rgb"));
428/// assert!(!matches_pattern("/sensors/*", "/control/motor"));
429///
430/// // Advanced patterns
431/// assert!(matches_pattern("/sensors/**", "/sensors/camera/left/rgb"));
432/// assert!(matches_pattern("/sensors/camera?", "/sensors/camera1"));
433/// assert!(matches_pattern("/sensors/{left,right}", "/sensors/left"));
434/// ```
435pub fn matches_pattern(pattern: &str, topic: &str) -> bool {
436    crate::aggregated::pattern::matches_pattern(topic, pattern)
437}