Skip to main content

rmqtt_acl/
lib.rs

1//! ACL (Access Control List) plugin for RMQTT.
2//!
3//! Provides rule-based publish/subscribe authorization using
4//! configurable allow/deny rules with topic pattern matching.
5//!
6//! # Rule Evaluation
7//!
8//! Rules are evaluated in order. The first matching rule determines
9//! the authorization decision. If no rules match, the default action
10//! (allow/deny) applies.
11//!
12//! Each rule specifies:
13//! - `action`: `allow` or `deny`
14//! - `username`: Optional client username match
15//! - `clientid`: Optional client ID match
16//! - `topic`: Topic filter for the rule
17//! - `action`: `publish` or `subscribe` (or both)
18//!
19#![deny(unsafe_code)]
20
21use std::str::FromStr;
22use std::sync::Arc;
23
24use async_trait::async_trait;
25use tokio::{self, sync::RwLock};
26
27use rmqtt::{
28    codec::v5::SubscribeAckReason,
29    context::ServerContext,
30    hook::{Handler, HookResult, Parameter, Register, ReturnType, Type},
31    macros::Plugin,
32    plugin::{PackageInfo, Plugin},
33    register,
34    types::{AuthResult, PublishAclResult, SubscribeAclResult, Topic},
35    Result,
36};
37
38use config::{Access, Control, PluginConfig, PH_C, PH_U};
39
40mod config;
41
42register!(AclPlugin::new);
43
44const CACHE_KEY: &str = "$SYS/ACL-CACHE-MAP";
45
46#[derive(Plugin)]
47struct AclPlugin {
48    scx: ServerContext,
49    register: Box<dyn Register>,
50    cfg: Arc<RwLock<PluginConfig>>,
51}
52
53impl AclPlugin {
54    #[inline]
55    async fn new<N: Into<String>>(scx: ServerContext, name: N) -> Result<Self> {
56        let name = name.into();
57        let cfg = scx.plugins.read_config_default::<PluginConfig>(&name);
58        log::info!("{name} AclPlugin cfg: {cfg:?}");
59        let cfg = Arc::new(RwLock::new(cfg?));
60        let register = scx.extends.hook_mgr().register();
61        Ok(Self { scx, register, cfg })
62    }
63}
64
65#[async_trait]
66impl Plugin for AclPlugin {
67    #[inline]
68    async fn init(&mut self) -> Result<()> {
69        log::info!("{} init", self.name());
70        let cfg = &self.cfg;
71        let priority = cfg.read().await.priority;
72        self.register.add_priority(Type::ClientConnected, priority, Box::new(AclHandler::new(cfg))).await;
73        self.register.add_priority(Type::ClientDisconnected, priority, Box::new(AclHandler::new(cfg))).await;
74        self.register.add_priority(Type::ClientAuthenticate, priority, Box::new(AclHandler::new(cfg))).await;
75        self.register
76            .add_priority(Type::ClientSubscribeCheckAcl, priority, Box::new(AclHandler::new(cfg)))
77            .await;
78        self.register
79            .add_priority(Type::MessagePublishCheckAcl, priority, Box::new(AclHandler::new(cfg)))
80            .await;
81        Ok(())
82    }
83
84    #[inline]
85    async fn get_config(&self) -> Result<serde_json::Value> {
86        self.cfg.read().await.to_json()
87    }
88
89    #[inline]
90    async fn load_config(&mut self) -> Result<()> {
91        let new_cfg = self.scx.plugins.read_config::<PluginConfig>(self.name())?;
92        *self.cfg.write().await = new_cfg;
93        log::debug!("load_config ok,  {:?}", self.cfg);
94        Ok(())
95    }
96
97    #[inline]
98    async fn start(&mut self) -> Result<()> {
99        log::info!("{} start", self.name());
100        self.register.start().await;
101        Ok(())
102    }
103
104    #[inline]
105    async fn stop(&mut self) -> Result<bool> {
106        log::warn!("{} stop, the default ACL plug-in, it cannot be stopped", self.name());
107        //self.register.stop().await;
108        Ok(false)
109    }
110}
111
112struct AclHandler {
113    cfg: Arc<RwLock<PluginConfig>>,
114}
115
116impl AclHandler {
117    fn new(cfg: &Arc<RwLock<PluginConfig>>) -> Self {
118        Self { cfg: cfg.clone() }
119    }
120}
121
122#[async_trait]
123impl Handler for AclHandler {
124    async fn hook(&self, param: &Parameter, acc: Option<HookResult>) -> ReturnType {
125        match param {
126            Parameter::ClientConnected(session) => {
127                let cfg = self.cfg.clone();
128                let client_id = session.id.client_id.clone();
129                let username = session.id.username.clone();
130                let extra_attrs = session.extra_attrs.clone();
131
132                let build_placeholders = async move {
133                    for rule in cfg.read().await.rules() {
134                        for ph_tf in &rule.topics.placeholders {
135                            let mut tf = ph_tf.replace(PH_C, &client_id);
136                            if let Some(un) = &username {
137                                tf = tf.replace(PH_U, un);
138                            } else {
139                                tf = tf.replace(PH_U, "");
140                            }
141                            if let Err(e) = rule.add_topic_filter(&tf, client_id.clone()).await {
142                                log::error!(
143                                    "acl config error, build_placeholders, add topic filter error, {e}"
144                                );
145                            }
146                            log::debug!("topic filter: {tf}");
147                            if let Some(caches) =
148                                extra_attrs.write().await.get_default_mut(CACHE_KEY.into(), Vec::default)
149                            {
150                                caches.push(tf);
151                            }
152                        }
153
154                        for eq_ph_t in &rule.topics.eq_placeholders {
155                            let mut t = eq_ph_t.replace(PH_C, &client_id);
156                            if let Some(un) = &username {
157                                t = t.replace(PH_U, un);
158                            } else {
159                                t = t.replace(PH_U, "");
160                            }
161                            log::info!("eq topic: {t}");
162                            rule.add_topic_to_eqs(t);
163                        }
164
165                        log::debug!("rule.access: {:?}", rule.access);
166                        log::debug!("rule.users: {:?}", rule.users);
167                        log::debug!("rule.control: {:?}", rule.control);
168                        log::debug!("rule.topics.eqs: {:?}", rule.topics.eqs);
169                        log::debug!("rule.topics.tree: {:?}", rule.topics.tree.read().await.list(100));
170                        log::debug!("rule.topics.placeholders: {:?}", rule.topics.placeholders);
171                    }
172                };
173                tokio::spawn(build_placeholders);
174            }
175
176            Parameter::ClientDisconnected(session, _reason) => {
177                if let Some(topic_filters) = session.extra_attrs.read().await.get::<Vec<String>>(CACHE_KEY) {
178                    let client_id = session.id.client_id.clone();
179                    for topic_filter in topic_filters {
180                        for rule in self.cfg.read().await.rules() {
181                            if let Err(e) = rule.remove_topic(topic_filter.as_str(), &client_id).await {
182                                log::error!("remove topic filter error, {e}");
183                            }
184                        }
185                    }
186                };
187            }
188
189            Parameter::ClientAuthenticate(connect_info) => {
190                log::debug!("ClientAuthenticate acl");
191                if matches!(
192                    acc,
193                    Some(HookResult::AuthResult(AuthResult::BadUsernameOrPassword))
194                        | Some(HookResult::AuthResult(AuthResult::NotAuthorized))
195                ) {
196                    return (false, acc);
197                }
198
199                for rule in self.cfg.read().await.rules() {
200                    if !matches!(rule.control, Control::Connect | Control::All) {
201                        continue;
202                    }
203
204                    let allow = matches!(rule.access, Access::Allow);
205                    let (hit, superuser) = rule.hit(
206                        connect_info.id(),
207                        connect_info.password(),
208                        Some(connect_info.proto_ver()),
209                        allow,
210                    );
211                    if hit {
212                        log::debug!("{:?} ClientAuthenticate, rule: {:?}", connect_info.id(), rule);
213                        return if allow {
214                            (false, Some(HookResult::AuthResult(AuthResult::Allow(superuser, None))))
215                        } else {
216                            (false, Some(HookResult::AuthResult(AuthResult::NotAuthorized)))
217                        };
218                    }
219                }
220                return (false, Some(HookResult::AuthResult(AuthResult::NotAuthorized)));
221            }
222
223            Parameter::ClientSubscribeCheckAcl(session, subscribe) => {
224                if let Some(HookResult::SubscribeAclResult(acl_result)) = &acc {
225                    if acl_result.failure() {
226                        return (false, acc);
227                    }
228                }
229                let topic =
230                    Topic::from_str(&subscribe.topic_filter).unwrap_or_else(|_| Topic::from(Vec::new()));
231                let topic_filter = &subscribe.topic_filter;
232                for (idx, rule) in self.cfg.read().await.rules().iter().enumerate() {
233                    if !matches!(rule.control, Control::Subscribe | Control::Pubsub | Control::All) {
234                        continue;
235                    }
236
237                    let allow = matches!(rule.access, Access::Allow);
238                    let (hit, _) =
239                        rule.hit(&session.id, session.password(), session.protocol().await.ok(), allow);
240                    if !hit {
241                        continue;
242                    }
243                    if !rule.topics.is_match(&topic, topic_filter, &session.id.client_id).await {
244                        continue;
245                    }
246                    log::debug!(
247                        "{:?} ClientSubscribeCheckAcl, {}, is_match ok: topic_filter: {}",
248                        session.id,
249                        idx,
250                        topic_filter
251                    );
252                    return if allow {
253                        (
254                            false,
255                            Some(HookResult::SubscribeAclResult(SubscribeAclResult::new_success(
256                                subscribe.opts.qos(),
257                                None,
258                            ))),
259                        )
260                    } else {
261                        (
262                            false,
263                            Some(HookResult::SubscribeAclResult(SubscribeAclResult::new_failure(
264                                SubscribeAckReason::UnspecifiedError,
265                            ))),
266                        )
267                    };
268                }
269                return (
270                    false,
271                    Some(HookResult::SubscribeAclResult(SubscribeAclResult::new_failure(
272                        SubscribeAckReason::UnspecifiedError,
273                    ))),
274                );
275            }
276
277            Parameter::MessagePublishCheckAcl(session, publish) => {
278                if let Some(HookResult::PublishAclResult(acl_res)) = &acc {
279                    if acl_res.is_rejected() {
280                        return (false, acc);
281                    }
282                }
283
284                let topic_str = &publish.topic;
285                let topic = Topic::from_str(topic_str).unwrap_or_else(|_| Topic::from(Vec::new()));
286                let disconnect_if_pub_rejected = self.cfg.read().await.disconnect_if_pub_rejected;
287                for (idx, rule) in self.cfg.read().await.rules().iter().enumerate() {
288                    if !matches!(rule.control, Control::Publish | Control::Pubsub | Control::All) {
289                        continue;
290                    }
291
292                    let allow = matches!(rule.access, Access::Allow);
293                    let (hit, _) =
294                        rule.hit(&session.id, session.password(), session.protocol().await.ok(), allow);
295                    if !hit {
296                        continue;
297                    }
298                    if !rule.topics.is_match(&topic, topic_str, &session.id.client_id).await {
299                        continue;
300                    }
301                    log::debug!(
302                        "{:?} MessagePublishCheckAcl, {}, is_match ok: topic_str: {}",
303                        session.id,
304                        idx,
305                        topic_str
306                    );
307                    return if allow {
308                        (false, Some(HookResult::PublishAclResult(PublishAclResult::allow())))
309                    } else {
310                        (
311                            false,
312                            Some(HookResult::PublishAclResult(PublishAclResult::rejected(
313                                disconnect_if_pub_rejected,
314                                None,
315                            ))),
316                        )
317                    };
318                }
319                return (
320                    false,
321                    Some(HookResult::PublishAclResult(PublishAclResult::rejected(
322                        disconnect_if_pub_rejected,
323                        None,
324                    ))),
325                );
326            }
327            _ => {
328                log::error!("parameter is: {param:?}");
329            }
330        }
331        (true, acc)
332    }
333}