Skip to content

Commit 1317664

Browse files
authored
Add permissions (#55)
* feat: add acl on topics * chore: serialize as filter directly from serde, fix check_acl_config * chore: add test * fix: tests * chore: rename acl to permissions
1 parent 4ef1b86 commit 1317664

File tree

10 files changed

+263
-11
lines changed

10 files changed

+263
-11
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ foxmq.d/
66

77
# These are test keys and must not be used anywhere else.
88
!/tests/dmq
9+
.env

src/cli/run.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use tashi_collections::HashMap;
66
use crate::cli::LogFormat;
77
use crate::config;
88
use crate::config::addresses::Addresses;
9+
use crate::config::permissions::PermissionsConfig;
910
use crate::config::users::{AuthConfig, UsersConfig};
1011
use crate::mqtt::broker::{self, MqttBroker};
1112
use crate::mqtt::{KeepAlive, TceState};
@@ -181,6 +182,7 @@ impl SecretKeyOpt {
181182

182183
pub fn main(args: RunArgs) -> crate::Result<()> {
183184
let mut users = config::users::read(&args.config_dir.join("users.toml"))?;
185+
let acl = config::permissions::read(&args.config_dir.join("permissions.toml"))?;
184186

185187
// Merge any auth overrides from the command-line.
186188
users.auth.merge(&args.auth_config);
@@ -261,14 +263,15 @@ pub fn main(args: RunArgs) -> crate::Result<()> {
261263

262264
let ws_config = args.ws_config.websockets.then(|| args.ws_config.clone());
263265

264-
main_async(args, users, tce_config, tls_config, ws_config)
266+
main_async(args, users, acl, tce_config, tls_config, ws_config)
265267
}
266268

267269
// `#[tokio::main]` doesn't have to be attached to the actual `main()`, and it can accept args
268270
#[tokio::main]
269271
async fn main_async(
270272
args: RunArgs,
271273
users: UsersConfig,
274+
permissions_config: PermissionsConfig,
272275
tce_config: Option<TceConfig>,
273276
tls_config: Option<broker::TlsConfig>,
274277
ws_config: Option<WsConfig>,
@@ -298,6 +301,7 @@ async fn main_async(
298301
tls_config,
299302
ws_config,
300303
users,
304+
permissions_config,
301305
tce,
302306
KeepAlive::from_seconds(args.max_keep_alive),
303307
)

src/config/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::path::Path;
44
use std::{fs, io};
55

66
pub mod addresses;
7-
7+
pub mod permissions;
88
pub mod users;
99

1010
fn read_toml<T: DeserializeOwned>(name: &str, path: &Path) -> crate::Result<T> {

src/config/permissions.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
use std::{path::Path, str::FromStr};
2+
3+
use tashi_collections::HashMap;
4+
5+
use crate::mqtt::trie::Filter;
6+
7+
#[derive(serde::Deserialize, Default)]
8+
pub struct PermissionsConfig {
9+
#[serde(default)]
10+
pub permissions: HashMap<String, TopicsConfig>,
11+
}
12+
13+
#[derive(serde::Deserialize, Debug)]
14+
pub struct TopicsConfig {
15+
pub topic: Vec<TopicPermissions>,
16+
}
17+
18+
#[derive(serde::Deserialize, Debug)]
19+
pub struct TopicPermissions {
20+
#[serde(deserialize_with = "from_str")]
21+
pub filter: Filter,
22+
pub allowed: Vec<TransactionType>,
23+
#[serde(default)]
24+
pub denied: Vec<TransactionType>,
25+
}
26+
27+
fn from_str<'de, D>(deserializer: D) -> Result<Filter, D::Error>
28+
where
29+
D: serde::Deserializer<'de>,
30+
{
31+
let s: String = serde::Deserialize::deserialize(deserializer)?;
32+
33+
Filter::from_str(&s).map_err(serde::de::Error::custom)
34+
}
35+
36+
#[derive(serde::Deserialize, PartialEq, Eq, Debug)]
37+
#[serde(rename_all = "lowercase")]
38+
pub enum TransactionType {
39+
Subscribe,
40+
Publish,
41+
}
42+
43+
impl PermissionsConfig {
44+
pub fn get_topics_acl_config(&self, user: &str) -> Option<&TopicsConfig> {
45+
match self.permissions.get(user) {
46+
Some(permission) => Some(permission),
47+
None => self.permissions.get("*"),
48+
}
49+
}
50+
51+
pub fn check_acl_config(
52+
&self,
53+
topics_config: Option<&TopicsConfig>,
54+
topic_name: &str,
55+
transaction_type: TransactionType,
56+
) -> bool {
57+
// Allows everything if no topics config was found.
58+
topics_config.map_or(true, |perms| {
59+
perms
60+
.topic
61+
.iter()
62+
.find(|k| k.filter.matches_topic(topic_name))
63+
.map_or(true, |k| {
64+
k.allowed.iter().any(|k| *k == transaction_type)
65+
|| !k.denied.iter().all(|k| *k == transaction_type)
66+
})
67+
})
68+
}
69+
}
70+
71+
pub fn read(path: &Path) -> crate::Result<PermissionsConfig> {
72+
Ok(
73+
super::read_toml_optional("permissions", path)?.unwrap_or_else(|| {
74+
tracing::debug!(
75+
"permissions file not found at {}; any user can do anything with the topics.",
76+
path.display()
77+
);
78+
79+
PermissionsConfig::default()
80+
}),
81+
)
82+
}

src/mqtt/broker/connection.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,8 @@ impl<S: MqttSocket> Connection<S> {
461461

462462
self.protocol = protocol;
463463

464+
let mut user = "".to_string();
465+
464466
if connect_props.as_ref().is_some_and(|props| {
465467
props.authentication_method.is_some() || props.authentication_data.is_some()
466468
}) {
@@ -487,17 +489,16 @@ impl<S: MqttSocket> Connection<S> {
487489
};
488490

489491
if let Some(login) = login {
490-
let Some(user) = self.shared.users.by_username.get(&login.username) else {
492+
let Some(logged_user) = self.shared.users.by_username.get(&login.username) else {
491493
self.disconnect_on_connect_error(ConnectReturnCode::NotAuthorized, "unknown user")
492494
.await?;
493-
494495
return Ok(None);
495496
};
496497

497498
let verified = self
498499
.shared
499500
.password_hasher
500-
.verify(login.password.as_bytes(), &user.password_hash)
501+
.verify(login.password.as_bytes(), &logged_user.password_hash)
501502
.await?;
502503

503504
if !verified {
@@ -509,6 +510,8 @@ impl<S: MqttSocket> Connection<S> {
509510

510511
return Ok(None);
511512
}
513+
514+
user = login.username;
512515
} else if !self.shared.users.auth.allow_anonymous_login {
513516
self.disconnect_on_connect_error(
514517
ConnectReturnCode::NotAuthorized,
@@ -592,6 +595,7 @@ impl<S: MqttSocket> Connection<S> {
592595
self.id,
593596
response.client_index,
594597
client_id,
598+
user,
595599
store.mailbox.sender(),
596600
clean_session,
597601
)

src/mqtt/broker/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use connection::Connection;
2121
use rumqttd_protocol::QoS;
2222

2323
use crate::cli::run::WsConfig;
24+
use crate::config::permissions::PermissionsConfig;
2425
use crate::config::users::UsersConfig;
2526
use crate::mqtt::broker::socket::{DirectSocket, MqttSocket};
2627
use crate::mqtt::broker::tls::TlsAcceptor;
@@ -171,11 +172,13 @@ pub struct TlsConfig {
171172
}
172173

173174
impl MqttBroker {
175+
#[allow(clippy::too_many_arguments)]
174176
pub async fn bind(
175177
listen_addr: SocketAddr,
176178
tls_config: Option<TlsConfig>,
177179
ws_config: Option<WsConfig>,
178180
users: UsersConfig,
181+
permissions_config: PermissionsConfig,
179182
tce: Option<TceState>,
180183
max_keep_alive: KeepAlive,
181184
) -> crate::Result<Self> {
@@ -207,7 +210,7 @@ impl MqttBroker {
207210

208211
let tce_platform = tce.as_ref().map(|tce| tce.platform.clone());
209212

210-
let router = MqttRouter::start(tce, token.clone());
213+
let router = MqttRouter::start(tce, token.clone(), permissions_config);
211214

212215
Ok(MqttBroker {
213216
listen_addr,

src/mqtt/router.rs

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use tracing::{Instrument, Span};
2323

2424
use rumqttd_protocol::{QoS, RetainForwardRule, SubscribeReasonCode, UnsubAckReason};
2525

26+
use crate::config::permissions::PermissionsConfig;
2627
use crate::map_join_error;
2728
use crate::mqtt::mailbox::MailSender;
2829
use crate::mqtt::packets::PacketId;
@@ -77,7 +78,11 @@ pub struct TceState {
7778
}
7879

7980
impl MqttRouter {
80-
pub fn start(tce: Option<TceState>, token: CancellationToken) -> Self {
81+
pub fn start(
82+
tce: Option<TceState>,
83+
token: CancellationToken,
84+
permissions: PermissionsConfig,
85+
) -> Self {
8186
let (command_tx, command_rx) = mpsc::channel(COMMAND_CAPACITY);
8287

8388
let (system_tx, system_rx) = mpsc::unbounded_channel();
@@ -88,6 +93,7 @@ impl MqttRouter {
8893

8994
let state = RouterState {
9095
token,
96+
permissions,
9197
clients: SecondaryMap::new(),
9298
dead_clients: HashSet::default(),
9399
subscriptions: Subscriptions::default(),
@@ -159,6 +165,7 @@ impl RouterHandle {
159165
connection_id: ConnectionId,
160166
client_index: ClientIndex,
161167
client_id: ClientId,
168+
user: String,
162169
mail_tx: MailSender,
163170
clean_session: bool,
164171
) -> crate::Result<RouterConnection> {
@@ -170,6 +177,7 @@ impl RouterHandle {
170177
RouterCommand::NewConnection {
171178
connection_id,
172179
client_id,
180+
user,
173181
message_tx,
174182
mail_tx,
175183
clean_session,
@@ -284,6 +292,7 @@ enum RouterCommand {
284292
connection_id: ConnectionId,
285293
client_id: ClientId,
286294
mail_tx: MailSender,
295+
user: String,
287296
message_tx: mpsc::UnboundedSender<RouterMessage>,
288297
clean_session: bool,
289298
},
@@ -334,6 +343,8 @@ struct RouterState {
334343
clients: SecondaryMap<ClientIndex, ClientState>,
335344
dead_clients: HashSet<ClientIndex>,
336345

346+
permissions: PermissionsConfig,
347+
337348
subscriptions: Subscriptions,
338349
command_rx: mpsc::Receiver<(ClientIndex, RouterCommand)>,
339350
system_rx: mpsc::UnboundedReceiver<SystemCommand>,
@@ -351,6 +362,7 @@ struct RouterState {
351362
struct ClientState {
352363
client_id: ClientId,
353364
mail_tx: MailSender,
365+
user: String,
354366
subscriptions: ClientSubscriptions,
355367
current_connection: Option<ConnectionState>,
356368
clean_session: bool,
@@ -414,6 +426,15 @@ enum PublishOrigin<'a> {
414426
Consensus(&'a CreatorId),
415427
}
416428

429+
impl PublishOrigin<'_> {
430+
pub fn get_client_index(&self) -> Option<ClientIndex> {
431+
match self {
432+
PublishOrigin::Local(client_index) => Some(*client_index),
433+
_ => None,
434+
}
435+
}
436+
}
437+
417438
impl Index<SubscriptionKind> for Subscriptions {
418439
type Output = SubscriptionMap;
419440

@@ -567,6 +588,7 @@ fn handle_command(state: &mut RouterState, client_idx: ClientIndex, command: Rou
567588
RouterCommand::NewConnection {
568589
connection_id,
569590
client_id,
591+
user,
570592
message_tx,
571593
mail_tx,
572594
clean_session,
@@ -593,6 +615,7 @@ fn handle_command(state: &mut RouterState, client_idx: ClientIndex, command: Rou
593615
client_idx,
594616
ClientState {
595617
client_id,
618+
user,
596619
mail_tx,
597620
subscriptions: Default::default(),
598621
current_connection: Some(ConnectionState {
@@ -663,9 +686,11 @@ fn handle_subscribe(state: &mut RouterState, client_idx: ClientIndex, request: S
663686
publish: Arc<PublishTrasaction>,
664687
}
665688

666-
if !state.clients.contains_key(client_idx) {
689+
let Some(client) = state.clients.get(client_idx) else {
667690
return;
668-
}
691+
};
692+
693+
let permissions = state.permissions.get_topics_acl_config(&client.user);
669694

670695
// if state.connections[conn_id].message_tx.is_closed() {
671696
// return;
@@ -683,6 +708,14 @@ fn handle_subscribe(state: &mut RouterState, client_idx: ClientIndex, request: S
683708
// as they would have failed validation on the frontend.
684709
.ok_or(SubscribeReasonCode::Unspecified)
685710
.and_then(|(filter, props)| {
711+
if !state.permissions.check_acl_config(
712+
permissions,
713+
filter.as_str(),
714+
crate::config::permissions::TransactionType::Subscribe,
715+
) {
716+
Err(SubscribeReasonCode::NotAuthorized)?
717+
}
718+
686719
let sub_kind = SubscriptionKind::from_filter(&filter)
687720
.map_err(|_| SubscribeReasonCode::NotAuthorized)?;
688721

@@ -974,6 +1007,23 @@ fn dispatch(state: &mut RouterState, publish: Arc<PublishTrasaction>, origin: Pu
9741007
},
9751008
};
9761009

1010+
// Check if user has permission to publish
1011+
if let Some(client_index) = origin.get_client_index() {
1012+
let Some(client) = state.clients.get(client_index) else {
1013+
return;
1014+
};
1015+
1016+
let topics_config = state.permissions.get_topics_acl_config(&client.user);
1017+
1018+
if !state.permissions.check_acl_config(
1019+
topics_config,
1020+
&publish.topic,
1021+
crate::config::permissions::TransactionType::Publish,
1022+
) {
1023+
return;
1024+
}
1025+
}
1026+
9771027
// Only run this if TCE is not available.
9781028
if state.tce.is_none() && publish.meta.retain() {
9791029
let time_now = state.startup_time + state.startup_instant.elapsed();

tests/foxmq.d/permissions.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[permissions.test_user1]
2+
topic = [{ filter = "test_topic", allowed = ["publish"] }]
3+
4+
5+
[permissions.test_user2]
6+
topic = [{ filter = "test_topic", allowed = ["subscribe"] }]

tests/foxmq.d/users.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
[auth]
22
allow-anonymous-login = true
3+
4+
[users.test_user1]
5+
password-hash = "$argon2id$v=19$m=19456,t=2,p=1$TwFLGuZeIBQGjn02H9NpJQ$8s2JrmbxhLfHphYEfmZ4KjJmxS7tTCss06E27kZC6M0"
6+
7+
[users.test_user2]
8+
password-hash = "$argon2id$v=19$m=19456,t=2,p=1$TwFLGuZeIBQGjn02H9NpJQ$8s2JrmbxhLfHphYEfmZ4KjJmxS7tTCss06E27kZC6M0"

0 commit comments

Comments
 (0)