Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9871a53
feature: [174] Implement ACL DSL with Antlr
crazyrokr Jun 8, 2026
f23b48b
feature: [174] Integrate Antlr DSL into configs
crazyrokr Jun 8, 2026
c23b17d
feature: [174] Rewrite Java tests to Groovy Spock
crazyrokr Jun 8, 2026
35e3ff8
feature: [174] Move classes to other packages
crazyrokr Jun 8, 2026
e4f9b03
feature: [174] Rename userMatcher
crazyrokr Jun 9, 2026
935f819
feature: [174] Implement ACL DSL with Google Mug
crazyrokr Jun 9, 2026
77635a3
feature: [174] Replace Antlr with Mug
crazyrokr Jun 9, 2026
021d4a4
feature: [174] Apply formatting
crazyrokr Jun 9, 2026
7a10968
feature: [174] Fix bugs
crazyrokr Jun 9, 2026
0ea5c4b
feature: [174] Optimize AclRulesBuilder
crazyrokr Jun 9, 2026
a40553b
feature: [174] Revert redundant changes
crazyrokr Jun 9, 2026
975c1fa
feature: [174] Rename acl.engine.groovy.dsl.config to acl.engine.conf…
crazyrokr Jun 9, 2026
c9f4663
feature: [174] Remove redundant changes
crazyrokr Jun 9, 2026
85f8bd4
feature: [174] Remove redundant changes
crazyrokr Jun 9, 2026
1436586
feature: [174] Introduce ConfigurableBuilder
crazyrokr Jun 10, 2026
4241c98
feature: [174] Replace mutable array with array builder
crazyrokr Jun 10, 2026
7655ab1
feature: [174] Apply formatting
crazyrokr Jun 10, 2026
bc92846
feature: [174] Remove redundant code
crazyrokr Jun 11, 2026
357929d
feature: [174] Introduce inversion of control into Mug implementation
crazyrokr Jun 12, 2026
a7b6fbe
feature: [174] Add tests
crazyrokr Jun 12, 2026
0c61040
feature: [174] Add tests
crazyrokr Jun 12, 2026
f1640ba
feature: [174] Rename class fields
crazyrokr Jun 17, 2026
a608b82
feature: [174] Rename test methods
crazyrokr Jun 17, 2026
ddce9ef
feature: [174] Update exception message
crazyrokr Jun 17, 2026
32c3744
feature: [174] Apply formatting
crazyrokr Jun 17, 2026
72687df
feature: [174] Import both (Groovy and Mug) DSL
crazyrokr Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/.idea/
**/.gradle/
**/build/
/out/
**/out/
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ public class AclConfigurationException extends RuntimeException {
public AclConfigurationException(String message) {
super(message);
}

public AclConfigurationException(String message, Throwable cause) {
super(message, cause);
}
}
16 changes: 16 additions & 0 deletions acl-mug-dsl/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
id("groovy")
id("java-library")
id("configure-java")
}

dependencies {
api projects.aclEngine
api libs.rlib.collections

implementation libs.mug
implementation libs.mug.dot.parse

testImplementation testFixtures(projects.model)
testImplementation projects.testSupport
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package javasabr.mqtt.acl.mug.dsl.loader;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import javasabr.mqtt.acl.engine.builder.RuleContainerBuilder;
import javasabr.mqtt.acl.engine.exception.AclConfigurationException;
import javasabr.mqtt.acl.engine.model.rule.AclRule;
import javasabr.mqtt.acl.mug.dsl.parser.GaclParser;
import javasabr.mqtt.model.acl.Operation;
import javasabr.rlib.collections.array.Array;
import javasabr.rlib.collections.array.ArrayBuilder;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class AclRulesLoader {

private final GaclParser parser;

public Map<Operation, Array<AclRule>> load(Path aclConfigPath) {
if (Files.notExists(aclConfigPath)) {
throw new AclConfigurationException("Config file:[%s] doesn't exist".formatted(aclConfigPath));
}
String content;
try {
content = Files.readString(aclConfigPath);
} catch (IOException e) {
throw new AclConfigurationException("Failed to read ACL file:[%s]".formatted(aclConfigPath), e);
}
Array<AclRule> rules = new ArrayBuilder<>(AclRule.class)
.add(parser.parse(content))
.build();
return RuleContainerBuilder.groupRulesByOperation(rules);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package javasabr.mqtt.acl.mug.dsl.parser;

import com.google.common.labs.parse.Parser;
import com.google.mu.util.CharPredicate;
import java.util.List;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import javasabr.mqtt.acl.engine.exception.AclConfigurationException;
import javasabr.mqtt.acl.engine.model.condition.AllOfCondition;
import javasabr.mqtt.acl.engine.model.condition.AnyOfCondition;
import javasabr.mqtt.acl.engine.model.condition.ClientIdCondition;
import javasabr.mqtt.acl.engine.model.condition.IpAddressCondition;
import javasabr.mqtt.acl.engine.model.condition.MqttUserCondition;
import javasabr.mqtt.acl.engine.model.condition.TopicCondition;
import javasabr.mqtt.acl.engine.model.condition.UserNameCondition;
import javasabr.mqtt.acl.engine.model.matcher.TopicFilterMatcher;
import javasabr.mqtt.acl.engine.model.matcher.TopicMatcher;
import javasabr.mqtt.acl.engine.model.matcher.TopicNameMatcher;
import javasabr.mqtt.acl.engine.model.matcher.UserMatchers;
import javasabr.mqtt.acl.engine.model.matcher.ValueMatcher;
import javasabr.mqtt.acl.engine.model.matcher.dynamic.DynamicTopicMatcher;
import javasabr.mqtt.acl.engine.model.rule.AclRule;
import javasabr.mqtt.acl.engine.model.rule.AllowPublishAclRule;
import javasabr.mqtt.acl.engine.model.rule.AllowSubscribeAclRule;
import javasabr.mqtt.acl.engine.model.rule.DenyPublishAclRule;
import javasabr.mqtt.acl.engine.model.rule.DenySubscribeAclRule;
import javasabr.mqtt.model.topic.TopicFilter;
import javasabr.mqtt.model.topic.TopicName;
import javasabr.mqtt.model.topic.TopicValidator;
import javasabr.rlib.collections.array.Array;
import javasabr.rlib.collections.array.ArrayBuilder;
import javasabr.rlib.collections.array.ArrayCollectors;
import lombok.AccessLevel;
import lombok.experimental.FieldDefaults;

@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class GaclParser {

CharPredicate whitespace = CharPredicate.anyOf(" \t\r\n");

Parser<String> string = Parser.anyOf(
Parser.quotedByWithEscapes('"', '"', Parser.chars(1)),
Parser.quotedByWithEscapes('\'', '\'', Parser.chars(1)));

Parser<ValueMatcher<String>> userMatcher = Parser.anyOf(
Parser
.word("startsWith")
.then(string.between("(", ")"))
.map(UserMatchers::startsWith),
Parser
.word("contains")
.then(string.between("(", ")"))
.map(UserMatchers::contains),
Parser
.word("eq")
.then(string.between("(", ")"))
.map(UserMatchers::eq),
Parser
.word("regex")
.then(string.between("(", ")"))
.map(UserMatchers::regex),
Parser
.word("anyValue")
.followedBy("()")
.thenReturn(ValueMatcher.MATCH_ANY_STRING));

Parser<String> identityType = Parser.anyOf(
Parser.word("userName"),
Parser.word("clientId"),
Parser.word("ipAddress"));

Parser<String> identityBlockType = Parser.anyOf(
Parser.word("userNames"),
Parser.word("clientIds"),
Parser.word("ipAddresses"));

Parser<List<MqttUserCondition>> userCondition = Parser.define(uc -> Parser.<List<MqttUserCondition>>anyOf(
Parser
.word("anyUser")
.followedBy("()")
.thenReturn(List.of(MqttUserCondition.MATCH_ANY)),
Parser.sequence(identityType, userMatcher, (id, matcher) -> List.of(toUserCondition(id, matcher))),
Parser.sequence(
identityBlockType,
userMatcher
.atLeastOnce()
.between("{", "}"),
(id, matchers) -> matchers
.stream()
.map(matcher -> toUserCondition(id, matcher))
.collect(Collectors.toList())),
Parser
.word("allOf")
.then(uc
.atLeastOnce()
.between("{", "}")
.map(lists -> {
Array<MqttUserCondition> flat = lists
.stream()
.flatMap(List::stream)
.collect(ArrayCollectors.toArray(MqttUserCondition.class));
return List.of(new AllOfCondition(flat));
})),
Parser
.word("anyOf")
.then(uc
.atLeastOnce()
.between("{", "}")
.map(lists -> {
Array<MqttUserCondition> flat = lists
.stream()
.flatMap(List::stream)
.collect(ArrayCollectors.toArray(MqttUserCondition.class));
return List.of(new AnyOfCondition(flat));
}))));

Parser<MqttUserCondition> usersSection = Parser
.word("users")
.then(userCondition
.atLeastOnce()
.between("{", "}")
.map(lists -> {
Array<MqttUserCondition> flat = lists
.stream()
.flatMap(List::stream)
.collect(ArrayCollectors.toArray(MqttUserCondition.class));
if (flat.isEmpty()) {
return MqttUserCondition.MATCH_NONE;
}
if (flat.size() == 1) {
return flat.get(0);
}
return new AnyOfCondition(flat);
}));

Parser<TopicMatcher> topicMatcher = Parser.anyOf(
Parser
.word("eq")
.then(string.between("(", ")"))
.map(s -> {
if (!TopicValidator.validateTopicName(s)) {
throw new AclConfigurationException("Invalid topic name:[%s]".formatted(s));
}
return (TopicMatcher) new TopicNameMatcher(TopicName.valueOf(s));
}),
Parser
.word("match")
.then(string.between("(", ")"))
.map(s -> {
if (!TopicValidator.validateTopicFilter(s)) {
throw new AclConfigurationException("Invalid topic filter:[%s]".formatted(s));
}
return (TopicMatcher) new TopicFilterMatcher(TopicFilter.valueOf(s));
}),
Parser
.word("dynamic")
.then(string.between("(", ")"))
.map(s -> {
try {
return DynamicTopicMatcher.autoBuild(s);
} catch (RuntimeException e) {
throw new AclConfigurationException("Invalid dynamic topic pattern", e);
}
}),
Parser
.word("anyTopic")
.followedBy("()")
.thenReturn(TopicMatcher.MATCH_ANY));

Parser<Array<TopicMatcher>> topicsSection = Parser
.word("topics")
.then(topicMatcher
.atLeastOnce()
.between("{", "}")
.map(matchers -> new ArrayBuilder<>(TopicMatcher.class).add(matchers))
.map(ArrayBuilder::build));

Parser<AclRule> directive = Parser.anyOf(
createDirective("allowPublish", AllowPublishAclRule::new),
createDirective("denyPublish", DenyPublishAclRule::new),
createDirective("allowSubscribe", AllowSubscribeAclRule::new),
createDirective("denySubscribe", DenySubscribeAclRule::new));

public List<AclRule> parse(String input) {
try {
return directive
.atLeastOnce()
.parseSkipping(whitespace, input);
} catch (Parser.ParseException e) {
throw new AclConfigurationException("Syntax error at %s".formatted(toLineColumn(input, e.getSourceIndex())));
}
}

private Parser<AclRule> createDirective(
String keyword,
BiFunction<MqttUserCondition, TopicCondition, AclRule> factory) {
return Parser
.word(keyword)
.then(Parser
.sequence(usersSection, topicsSection, (uc, tm) -> factory.apply(uc, new TopicCondition(tm)))
.between("{", "}"));
}

private MqttUserCondition toUserCondition(String identityType, ValueMatcher<String> matcher) {
return switch (identityType) {
case "userName", "userNames" -> new UserNameCondition(matcher);
case "clientId", "clientIds" -> new ClientIdCondition(matcher);
case "ipAddress", "ipAddresses" -> new IpAddressCondition(matcher);
default -> throw new AclConfigurationException("Unknown identity type: %s".formatted(identityType));
};
}

private String toLineColumn(String input, int index) {
int line = 1;
int col = 1;
for (int i = 0; i < index && i < input.length(); i++) {
if (input.charAt(i) == '\n') {
line++;
col = 1;
} else {
col++;
}
}
return "line %s, column %s".formatted(line, col);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package javasabr.mqtt.acl.mug.dsl.loader

import javasabr.mqtt.acl.engine.exception.AclConfigurationException
import javasabr.mqtt.acl.mug.dsl.parser.GaclParser
import spock.lang.Specification

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths

class AclRulesLoaderTest extends Specification {

GaclParser parser
AclRulesLoader loader

def setup() {
parser = Mock(GaclParser)
loader = new AclRulesLoader(parser)
}

def "should throw exception when file does not exist"() {
given:
Path nonExistent = Paths.get("non-existent.gacl")

when:
loader.load(nonExistent)

then:
thrown(AclConfigurationException)
}

def "should throw exception when file read fails"() {
given:
Path unreadableFile = Files.createTempFile("unreadable", ".gacl")
// Make the file unreadable to trigger IOException during Files.readString
unreadableFile.toFile().setReadable(false)

when:
loader.load(unreadableFile)

then:
thrown(AclConfigurationException)

cleanup:
unreadableFile.toFile().setReadable(true)
Files.deleteIfExists(unreadableFile)
}

def "should load rules successfully"() {
given:
Path tempFile = Files.createTempFile("test", ".gacl")
Files.writeString(tempFile, "mock content")

and:
parser.parse("mock content") >> []

when:
def rules = loader.load(tempFile)

then:
rules != null

cleanup:
Files.deleteIfExists(tempFile)
}
}
Loading
Loading