Skip to content

Commit c73dc2a

Browse files
authored
[broker-35] Implement ACL rules engine (#59)
* [broker-35] HCL-format playbook * [broker-35] Groovy-DSL playbook * [broker-35] Improve Groovy-DSL * [broker-35] Move Groovy-DSL to main sources * [broker-35] Code cleanup * [broker-35] Extract ACL parser and engine to separate modules * [broker-35] Apply schema to config * [broker-35] Optimize AclRuleEngine * [broker-35] Optimize lambda usage * [broker-35] Replace groovy-all dependency with groovy * [broker-35] Remove testFixtures(projects.network) from acl-engine * [broker-35] Include package-info.java to acl-engine * [broker-35] Introduce OOP into client matching logic * [broker-35] Fix small issues * [broker-35] Implement nested allOf/anyOf * [broker-35] Work on PR comments * [broker-35] Clean up the code * [broker-35] Move model classes from groovy to java * [broker-35] Move TopicFilter.matches() to TopicFilterMatcher * [broker-35] Improve test * [broker-35] Fix gdsl definition * [broker-35] Remove redundant class members * [broker-35] Introduce anyOf() and anyone() matchers * [broker-35] Add tests * [broker-35] Improve TopicFilterMatcher * [broker-35] Add test * [broker-35] Improve Rule abstraction * [broker-35] Fix rule application * [broker-35] Make AclRulesLoader returning EnumMap * [broker-35] Increase performance of AclRuleLoader * [broker-35] Code cleanup * [broker-35] Refactoring * [broker-35] Increase test coverage * [broker-35] Adapt AclRuleEngine to MqttUser interface * [broker-35] Move AclConfigurationException to java * [broker-35] Remove redundant code * [broker-35] Improve AllOfBuilder encapsulation * [broker-35] Add more test cases * [broker-35] Increase test coverage * [broker-35] Ensure each operation has non-null rule list * [broker-35] Update the same comment with Coverage Report * [broker-35] Improve EnumMap<Rule> creation * [broker-35] Add more tests * [broker-35] Work on review comments * [broker-35] Accept AbstractTopic instead of rawTopic * [broker-35] Support anyone() for user identity matchers * [broker-35] Renaming, refactoring, code cleanup * [broker-35] Add README.md * [broker-35] Update README.md
1 parent 653f1a1 commit c73dc2a

File tree

74 files changed

+1809
-33
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

74 files changed

+1809
-33
lines changed

.github/workflows/gradle.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ jobs:
5454
token: ${{ secrets.GITHUB_TOKEN }}
5555
min-coverage-overall: 40
5656
min-coverage-changed-files: 60
57+
update-comment: true
58+
title: Test Coverage Report
5759

5860
dependency-submission:
5961
runs-on: ubuntu-latest

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
/.idea/
2-
/.gradle/
3-
/build/
2+
**/.gradle/
3+
**/build/
44
/out/

acl-engine/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
## Summary
2+
3+
This module implements an Access Control List rules engine for the MQTT broker.
4+
**_ACL Rules Engine_** stores rules parsed by **_ACL Rules Loader_** and verify user authorization requests against the rules.
5+
It utilizes order-based priority: once a rule matches, its permission (allow or deny) is applied and subsequent rules
6+
are skipped. By default, it denies any incoming request unless it's allowed explicitly.

acl-engine/build.gradle

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
plugins {
2+
id("groovy")
3+
id("java-library")
4+
id("configure-java")
5+
}
6+
7+
dependencies {
8+
api projects.aclGroovyDsl
9+
api projects.model
10+
api libs.groovy
11+
12+
testImplementation projects.testSupport
13+
testImplementation testFixtures(projects.aclGroovyDsl)
14+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package javasabr.mqtt.service.acl;
2+
3+
import java.util.Map;
4+
import javasabr.mqtt.model.MqttUser;
5+
import javasabr.mqtt.model.acl.Action;
6+
import javasabr.mqtt.model.acl.Operation;
7+
import javasabr.mqtt.model.acl.rule.Rule;
8+
import javasabr.mqtt.model.topic.AbstractTopic;
9+
import javasabr.rlib.collections.array.Array;
10+
11+
public record AclRulesEngine(Map<Operation, Array<Rule>> ruleMap) {
12+
13+
public boolean authorize(MqttUser mqttUser, Operation operation, AbstractTopic topic) {
14+
Array<Rule> rules = ruleMap.get(operation);
15+
for (Rule rule : rules) {
16+
if (rule.test(mqttUser, operation, topic)) {
17+
return rule.action() == Action.ALLOW;
18+
}
19+
}
20+
return false;
21+
}
22+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@NullMarked
2+
package javasabr.mqtt.service.acl;
3+
4+
import org.jspecify.annotations.NullMarked;
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package javasabr.mqtt.service.acl
2+
3+
import javasabr.mqtt.model.MqttUser
4+
import javasabr.mqtt.model.acl.Operation
5+
import javasabr.mqtt.model.acl.condition.AllOfCondition
6+
import javasabr.mqtt.model.acl.condition.AnyOfCondition
7+
import javasabr.mqtt.model.acl.condition.MqttUserCondition
8+
import javasabr.mqtt.model.acl.condition.TopicCondition
9+
import javasabr.mqtt.model.acl.matcher.TopicNameMatcher
10+
import javasabr.mqtt.model.acl.rule.AllowPublishRule
11+
import javasabr.mqtt.model.acl.rule.AllowSubscribeRule
12+
import javasabr.mqtt.model.acl.rule.DenyPublishRule
13+
import javasabr.mqtt.model.acl.rule.DenySubscribeRule
14+
import javasabr.mqtt.model.acl.rule.Rule
15+
import javasabr.mqtt.model.topic.AbstractTopic
16+
import javasabr.mqtt.model.topic.TopicFilter
17+
import javasabr.mqtt.model.topic.TopicName
18+
import javasabr.mqtt.service.acl.builder.TopicMatcherBuilder
19+
import javasabr.mqtt.test.support.UnitSpecification
20+
import javasabr.rlib.collections.array.Array
21+
import javasabr.rlib.collections.array.MutableArray
22+
23+
import static javasabr.mqtt.model.acl.Operation.PUBLISH
24+
import static javasabr.mqtt.model.acl.Operation.SUBSCRIBE
25+
26+
class AclRulesEngineTest extends UnitSpecification implements ConditionMatcherAware, TopicMatcherBuilder {
27+
28+
def "should allow or deny according rules"(
29+
String username, String clientId, String ipAddress, Operation operation, AbstractTopic topic) {
30+
given:
31+
EnumMap<Operation, MutableArray<Rule>> rulesEnumMap = new EnumMap<>(Operation.class)
32+
and:
33+
Array<Rule> publishRules = MutableArray.ofType(Rule.class)
34+
rulesEnumMap.put(PUBLISH, publishRules)
35+
publishRules << new AllowPublishRule(
36+
new AnyOfCondition(
37+
userNameEquals("sensor1"),
38+
userNameEquals("sensor10"),
39+
userNameRegex("^sensor1/"),
40+
userNameRegex("/sensor10\$"),
41+
clientIdEquals("clientId1"),
42+
clientIdEquals("sensor10"),
43+
clientIdRegex("/^sensor1/"),
44+
clientIdRegex("/sensor10\$"),
45+
ipAddressEquals("10.56.0.3"),
46+
ipAddressRegex("127.0.0.1")
47+
),
48+
new TopicCondition(Array.of(
49+
new TopicNameMatcher(TopicName.valueOf("/topic1/#")),
50+
new TopicNameMatcher(TopicName.valueOf("/topic2/+/temp"))
51+
))
52+
)
53+
publishRules << new DenyPublishRule(
54+
new AllOfCondition(
55+
userNameEquals("user10"),
56+
clientIdEquals("clientId1"),
57+
ipAddressEquals("12.30.0.117"),
58+
),
59+
new TopicCondition(Array.of(
60+
new TopicNameMatcher(TopicName.valueOf("/topic/home/temp"))
61+
))
62+
)
63+
publishRules << new AllowPublishRule(
64+
new AnyOfCondition(
65+
userNameEquals("user120"),
66+
clientIdEquals("clientId500"),
67+
ipAddressEquals("12.30.0.117"),
68+
),
69+
new TopicCondition(Array.of(
70+
new TopicNameMatcher(TopicName.valueOf("/topic/home/temp"))
71+
))
72+
)
73+
publishRules << new AllowPublishRule(MqttUserCondition.MATCH_ANY, new TopicCondition(Array.of(
74+
new TopicNameMatcher(TopicName.valueOf("/topic1/#")),
75+
new TopicNameMatcher(TopicName.valueOf("/topic2/+/temp"))
76+
)))
77+
and:
78+
Array<Rule> subscribeRules = MutableArray.ofType(Rule.class)
79+
rulesEnumMap.put(SUBSCRIBE, subscribeRules)
80+
subscribeRules << new DenySubscribeRule(MqttUserCondition.MATCH_ANY, new TopicCondition(Array.of(
81+
match("/allowed/+/restricted")
82+
)))
83+
subscribeRules << new AllowSubscribeRule(new AllOfCondition(
84+
userNameEquals("admin"),
85+
clientIdEquals("id"),
86+
ipAddressEquals("10.0.0.1"),
87+
), new TopicCondition(Array.of(
88+
match("/allowed/#")
89+
)))
90+
subscribeRules << new DenySubscribeRule(MqttUserCondition.MATCH_ANY, new TopicCondition(Array.of(
91+
match("\$SYS/#"),
92+
match("#")
93+
)))
94+
subscribeRules << new AllowSubscribeRule(MqttUserCondition.MATCH_ANY, TopicCondition.MATCH_ANY)
95+
and:
96+
AclRulesEngine engine = new AclRulesEngine(rulesEnumMap)
97+
MqttUser mqttUser = Mock(MqttUser)
98+
mqttUser.userName() >> username
99+
mqttUser.clientId() >> clientId
100+
mqttUser.ipAddress() >> ipAddress
101+
when:
102+
boolean result = engine.authorize(mqttUser, operation, topic)
103+
then:
104+
result == expectedResult
105+
where:
106+
username | clientId | ipAddress | operation | topic | expectedResult
107+
"sensor1" | "clientId2" | "60.50.0.1" | PUBLISH | TopicName.valueOf("/topic1/#") | true
108+
"sensor2" | "clientId1" | "60.50.0.1" | PUBLISH | TopicName.valueOf("/topic1/#") | true
109+
"sensor2" | "clientId2" | "127.0.0.1" | PUBLISH | TopicName.valueOf("/topic1/#") | true
110+
"sensor2" | "clientId2" | "127.0.0.2" | PUBLISH | TopicName.valueOf("/topic1/#") | true
111+
"sensor2" | "clientId2" | "127.0.0.2" | PUBLISH | TopicName.valueOf("/topic/#") | false
112+
"user10" | "clientId1" | "12.30.0.117" | PUBLISH | TopicName.valueOf("/topic/home/temp") | false
113+
"user10" | "clientId1" | "12.30.0.117" | PUBLISH | TopicName.valueOf("/topic1/#") | true
114+
"user10" | "clientId1" | "12.30.0.117" | PUBLISH | TopicName.valueOf("/topic/home/temp") | false
115+
"user120" | "clientId1" | "12.30.0.117" | PUBLISH | TopicName.valueOf("/topic/home/temp") | true
116+
"sensorX" | "id" | "1.1.1.1" | PUBLISH | TopicName.valueOf("/topic1/data") | false
117+
"sensors1" | "id" | "1.1.1.1" | PUBLISH | TopicName.valueOf("/topic1/data") | false
118+
"sensor1" | "id" | "1.1.1.1" | PUBLISH | TopicName.valueOf("/topic2/temp") | false
119+
"sensor1/" | "id" | "1.1.1.1" | PUBLISH | TopicName.valueOf("/topic1/#") | true
120+
"user10" | "clientId1" | "12.30.0.117" | PUBLISH | TopicName.valueOf("/topic/home/temp") | false
121+
"user10" | "clientId2" | "12.30.0.117" | PUBLISH | TopicName.valueOf("/topic/home/temp") | true
122+
"nobody" | "none" | "0.0.0.0" | PUBLISH | TopicName.valueOf("/unknown/topic") | false
123+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("system/status") | false
124+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("\$SYS/info") | false
125+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/topic") | true
126+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/topic/#") | true
127+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/+") | true
128+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/+/temp") | true
129+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/sub1/restricted") | false
130+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/+/restricted") | false
131+
"username" | "clientId" | "127.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/topic/#") | false
132+
"user" | "id" | "10.0.0.10" | SUBSCRIBE | TopicFilter.valueOf("home/temp/status") | false
133+
"user" | "id" | "10.0.0.10" | SUBSCRIBE | TopicFilter.valueOf("\$SYS/info") | false
134+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/data") | true
135+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/#") | true
136+
"admin" | "id" | "10.0.0.2" | SUBSCRIBE | TopicFilter.valueOf("/allowed/data") | false
137+
"nobody" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/data") | false
138+
"user" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("home/status") | false
139+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed") | true
140+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/allowed/a/b/c/d") | true
141+
"admin" | "id" | "10.0.0.1" | SUBSCRIBE | TopicFilter.valueOf("/other/topic") | false
142+
}
143+
}

acl-groovy-dsl/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
## Summary
2+
3+
This module implements an Access Control List rules loader for the MQTT broker.
4+
It enables defining fine-grained permissions for clients — controlling who can publish or subscribe to which topics,
5+
based on client identifiers such as username, client id and IP-address.
6+
7+
**_ACL Rules Loader_** is a DSL-based configuration parser inspired by HCL format, allowing specifying rules like
8+
`allowPublish`, `denySubscribe`, `allowSubscribe` and `denyPublish`, supporting nesting of `allOf` and `anyOf`
9+
conditions and wildcards with `anyOf()` and `anyone()` keywords. An example of simple ACL file can be found in tests:
10+
11+
https://github.com/JavaSaBr/mqtt-broker/blob/d07808bbd9eaf0264853c832dc83d34e4f591c9d/acl-groovy-dsl/src/test/resources/acl/config/acl.groovy#L3-L50
12+
13+
### Domain Specific Language
14+
15+
#### Rules
16+
- `allowPublish` - defines allowing rule for publish operation
17+
- `denySubscribe` - defines denying rule for subscribe operation
18+
- `allowSubscribe` - defines allowing rule for subscribe operation
19+
- `denyPublish` - defines denying rule for publish operation
20+
#### Compose Conditions
21+
- `allOf` - conjunction condition (logical AND) of user conditions, allows only single-matcher members
22+
- `anyOf` - disjunction condition (logical OR) of other conditions, allows multi-matcher members including `allOf`/`anyOf`
23+
#### User Conditions
24+
- `username` - defines user matcher based on its username, supports multi-matchers
25+
- `clientId` - defines user matcher based on its client id, supports multi-matchers
26+
- `ipAddress` - defines user matcher based on its ip address, supports multi-matchers
27+
#### Topic Conditions
28+
- `topicName` - topic name condition for publish request, supports multi-matchers
29+
- `topicFilter` - topic filter condition for subscribe request, supports multi-matchers
30+
#### Value Matchers
31+
- `eq` - strict equality matcher
32+
- `regex` - regular expression matcher
33+
- `match` - topic filter matcher (respecting topic naming rules and wildcards support)
34+
35+
#### Constraints
36+
37+
- Maximum number of rules 1000
38+
- Rule can contain only one compose condition
39+
- Publish rule can contain several topic names
40+
- Subscribe rule can contain several topic filters
41+
- AllOf condition can contain only single client id, username or IP-address matchers
42+
- AnyOf condition can contain multiple client id, username, IP-address matchers, or other AllOf and AnyOf conditions
43+
- Username, Client ID and IP address conditions accept one or many regular expression or strict equality matchers
44+
- Topic name condition accepts one or many strict equality matchers
45+
- Topic filter condition accepts one or many topic filter matchers
46+
- Strict equality matcher compares two string values
47+
- Regular expression matcher verifies if incoming value match regex
48+
- Topic filter matcher verifies if incoming topic match subscription topic filter respecting `#` and `+` wildcards

acl-groovy-dsl/build.gradle

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
plugins {
2+
id("groovy")
3+
id("java-library")
4+
id("configure-java")
5+
}
6+
7+
dependencies {
8+
api libs.groovy
9+
api projects.model
10+
api libs.rlib.collections
11+
12+
testImplementation testFixtures(projects.model)
13+
testImplementation projects.testSupport
14+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package javasabr.mqtt.service.acl
2+
3+
import groovy.transform.Field
4+
import javasabr.mqtt.model.acl.Operation
5+
import javasabr.mqtt.model.acl.rule.Rule
6+
import javasabr.mqtt.model.exception.AclConfigurationException
7+
import javasabr.mqtt.service.acl.builder.AclRulesBuilder
8+
import javasabr.mqtt.service.acl.builder.RuleContainerBuilder
9+
import javasabr.rlib.collections.array.Array
10+
import org.codehaus.groovy.control.CompilerConfiguration
11+
12+
import java.nio.file.Files
13+
import java.nio.file.Path
14+
15+
class AclRulesLoader {
16+
17+
@SuppressWarnings('GrFinalVariableAccess')
18+
private final Path aclConfigPath
19+
20+
AclRulesLoader(String aclConfigPath) {
21+
if (aclConfigPath == null) {
22+
throw new AclConfigurationException("ACL config path is null")
23+
}
24+
this.aclConfigPath = Path.of(aclConfigPath)
25+
if (Files.notExists(this.aclConfigPath)) {
26+
throw new AclConfigurationException("Class loader unable to load resource: %s".formatted(aclConfigPath))
27+
}
28+
}
29+
30+
Map<Operation, Array<Rule>> load() {
31+
CompilerConfiguration compilerConfig = new CompilerConfiguration()
32+
AclRulesBuilder aclRulesBuilder = new AclRulesBuilder()
33+
new GroovyShell(compilerConfig).with {
34+
setVariable("allowPublish", aclRulesBuilder.&allowPublish)
35+
setVariable("denyPublish", aclRulesBuilder.&denyPublish)
36+
setVariable("allowSubscribe", aclRulesBuilder.&allowSubscribe)
37+
setVariable("denySubscribe", aclRulesBuilder.&denySubscribe)
38+
evaluate(aclConfigPath.toFile())
39+
}
40+
def rules = aclRulesBuilder.build()
41+
return RuleContainerBuilder.groupRulesByOperation(rules)
42+
}
43+
}

0 commit comments

Comments
 (0)