Skip to content

Commit 7e28d13

Browse files
sguilhenpedroigor
authored andcommitted
Add workflow condition that uses boolean expressions to combine and negate conditions
Closes #42583 Signed-off-by: Stefan Guilhen <[email protected]>
1 parent eca9246 commit 7e28d13

File tree

16 files changed

+744
-3
lines changed

16 files changed

+744
-3
lines changed

model/jpa/pom.xml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@
8787
<artifactId>jackson-core</artifactId>
8888
<scope>provided</scope>
8989
</dependency>
90+
<dependency>
91+
<groupId>org.antlr</groupId>
92+
<artifactId>antlr4-runtime</artifactId>
93+
<scope>provided</scope>
94+
</dependency>
9095
<dependency>
9196
<groupId>junit</groupId>
9297
<artifactId>junit</artifactId>
@@ -134,6 +139,38 @@
134139
</systemPropertyVariables>
135140
</configuration>
136141
</plugin>
142+
<plugin>
143+
<groupId>org.antlr</groupId>
144+
<artifactId>antlr4-maven-plugin</artifactId>
145+
<configuration>
146+
<visitor>true</visitor>
147+
</configuration>
148+
<executions>
149+
<execution>
150+
<goals>
151+
<goal>antlr4</goal>
152+
</goals>
153+
</execution>
154+
</executions>
155+
</plugin>
156+
<plugin>
157+
<groupId>org.codehaus.mojo</groupId>
158+
<artifactId>build-helper-maven-plugin</artifactId>
159+
<executions>
160+
<execution>
161+
<id>add-source</id>
162+
<phase>generate-sources</phase>
163+
<goals>
164+
<goal>add-source</goal>
165+
</goals>
166+
<configuration>
167+
<sources>
168+
<source>${project.build.directory}/generated-sources/antlr4</source>
169+
</sources>
170+
</configuration>
171+
</execution>
172+
</executions>
173+
</plugin>
137174
</plugins>
138175
</build>
139176
</project>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
grammar BooleanCondition;
2+
3+
// Parser Rules
4+
evaluator : expression EOF;
5+
6+
expression : expression OR andExpression | andExpression;
7+
andExpression : andExpression AND notExpression | notExpression;
8+
notExpression : '!' notExpression | atom;
9+
10+
atom : LPAREN expression RPAREN
11+
| conditionCall
12+
;
13+
14+
conditionCall : Identifier LPAREN parameterList? RPAREN ;
15+
parameterList : StringLiteral (COMMA StringLiteral)* ;
16+
17+
// Lexer Rules
18+
OR : 'OR';
19+
AND : 'AND';
20+
NOT : '!';
21+
22+
Identifier : [a-zA-Z_][a-zA-Z_0-9-]*;
23+
StringLiteral : '"' ( ~'"' | '""' )* '"' ;
24+
25+
// Explicitly defined tokens for the characters
26+
LPAREN : '(';
27+
RPAREN : ')';
28+
COMMA : ',';
29+
30+
WS : [ \t\r\n]+ -> skip;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package org.keycloak.models.workflow.conditions;
2+
3+
import org.keycloak.models.KeycloakSession;
4+
import org.keycloak.models.KeycloakSessionFactory;
5+
import org.keycloak.models.workflow.WorkflowConditionProviderFactory;
6+
7+
import java.util.List;
8+
import java.util.Map;
9+
10+
public class ExpressionWorkflowConditionFactory implements WorkflowConditionProviderFactory<ExpressionWorkflowConditionProvider> {
11+
12+
public static final String ID = "expression";
13+
public static final String EXPRESSION = "expression";
14+
15+
@Override
16+
public ExpressionWorkflowConditionProvider create(KeycloakSession session, Map<String, List<String>> config) {
17+
return new ExpressionWorkflowConditionProvider(session, config.getOrDefault(EXPRESSION, List.of()).stream().findFirst().orElse(""));
18+
}
19+
20+
@Override
21+
public ExpressionWorkflowConditionProvider create(KeycloakSession session, List<String> configParameters) {
22+
if (configParameters.size() > 1) {
23+
throw new IllegalArgumentException("Expected single configuration parameter (expression)");
24+
}
25+
return create(session, Map.of(EXPRESSION, configParameters));
26+
}
27+
28+
@Override
29+
public String getId() {
30+
return ID;
31+
}
32+
33+
@Override
34+
public void init(org.keycloak.Config.Scope config) {
35+
}
36+
37+
@Override
38+
public void postInit(KeycloakSessionFactory factory) {
39+
}
40+
41+
@Override
42+
public void close() {
43+
}
44+
45+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package org.keycloak.models.workflow.conditions;
2+
3+
import jakarta.persistence.criteria.CriteriaBuilder;
4+
import jakarta.persistence.criteria.CriteriaQuery;
5+
import jakarta.persistence.criteria.Predicate;
6+
import jakarta.persistence.criteria.Root;
7+
import org.antlr.v4.runtime.CharStream;
8+
import org.antlr.v4.runtime.CharStreams;
9+
import org.antlr.v4.runtime.CommonTokenStream;
10+
import org.keycloak.models.KeycloakSession;
11+
import org.keycloak.models.workflow.WorkflowConditionProvider;
12+
import org.keycloak.models.workflow.WorkflowEvent;
13+
import org.keycloak.models.workflow.WorkflowInvalidStateException;
14+
import org.keycloak.models.workflow.conditions.expression.BooleanConditionEvaluator;
15+
import org.keycloak.models.workflow.conditions.expression.BooleanConditionLexer;
16+
import org.keycloak.models.workflow.conditions.expression.BooleanConditionParser;
17+
import org.keycloak.models.workflow.conditions.expression.BooleanConditionParser.EvaluatorContext;
18+
import org.keycloak.models.workflow.conditions.expression.ErrorListener;
19+
import org.keycloak.models.workflow.conditions.expression.PredicateConditionEvaluator;
20+
21+
import java.util.stream.Collectors;
22+
23+
public class ExpressionWorkflowConditionProvider implements WorkflowConditionProvider {
24+
25+
private final String expression;
26+
private final KeycloakSession session;
27+
private EvaluatorContext evaluatorContext;
28+
29+
public ExpressionWorkflowConditionProvider(KeycloakSession session, String expression) {
30+
this.session = session;
31+
this.expression = expression;
32+
}
33+
34+
@Override
35+
public boolean evaluate(WorkflowEvent event) {
36+
validate();
37+
BooleanConditionEvaluator evaluator = new BooleanConditionEvaluator(session, event);
38+
return evaluator.visit(this.evaluatorContext);
39+
}
40+
41+
@Override
42+
public Predicate toPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<?> userRoot) {
43+
validate();
44+
PredicateConditionEvaluator evaluator = new PredicateConditionEvaluator(session, cb, query, userRoot);
45+
return evaluator.visit(this.evaluatorContext);
46+
}
47+
48+
@Override
49+
public void validate() {
50+
// to properly validate the expression, we need to parse it. We then cache the parsed context if the expression is valid
51+
// so we don't to parse it again if validate is called again on the same instance of the provider
52+
if (this.evaluatorContext == null) {
53+
CharStream charStream = CharStreams.fromString(expression);
54+
BooleanConditionLexer lexer = new BooleanConditionLexer(charStream);
55+
CommonTokenStream tokens = new CommonTokenStream(lexer);
56+
BooleanConditionParser parser = new BooleanConditionParser(tokens);
57+
58+
// this replaces the standard error listener, storing all parsing errors if the expressions is malformed
59+
ErrorListener errorListener = new ErrorListener();
60+
parser.removeErrorListeners();
61+
parser.addErrorListener(errorListener);
62+
63+
// parse the expression and check for errors
64+
EvaluatorContext context = parser.evaluator();
65+
if (errorListener.hasErrors()) {
66+
String lineSeparator = System.lineSeparator();
67+
String errorDetails = errorListener.getErrorMessages().stream()
68+
.map(error -> "- " + error)
69+
.collect(Collectors.joining(lineSeparator));
70+
71+
throw new WorkflowInvalidStateException(String.format("Invalid expression: %s%sError details:%s%s",
72+
expression, lineSeparator, lineSeparator, errorDetails));
73+
}
74+
this.evaluatorContext = context;
75+
}
76+
}
77+
78+
@Override
79+
public void close() {
80+
// no-op, nothing to close
81+
}
82+
}

model/jpa/src/main/java/org/keycloak/models/workflow/conditions/GroupMembershipWorkflowConditionFactory.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ public GroupMembershipWorkflowConditionProvider create(KeycloakSession session,
1717
return new GroupMembershipWorkflowConditionProvider(session, config.get(EXPECTED_GROUPS));
1818
}
1919

20+
@Override
21+
public GroupMembershipWorkflowConditionProvider create(KeycloakSession session, List<String> configParameters) {
22+
return new GroupMembershipWorkflowConditionProvider(session, configParameters);
23+
}
24+
2025
@Override
2126
public String getId() {
2227
return ID;

model/jpa/src/main/java/org/keycloak/models/workflow/conditions/IdentityProviderWorkflowConditionFactory.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ public IdentityProviderWorkflowConditionProvider create(KeycloakSession session,
1717
return new IdentityProviderWorkflowConditionProvider(session, config.get(EXPECTED_ALIASES));
1818
}
1919

20+
@Override
21+
public IdentityProviderWorkflowConditionProvider create(KeycloakSession session, List<String> configParameters) {
22+
return new IdentityProviderWorkflowConditionProvider(session, configParameters);
23+
}
24+
2025
@Override
2126
public String getId() {
2227
return ID;

model/jpa/src/main/java/org/keycloak/models/workflow/conditions/RoleWorkflowConditionFactory.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ public RoleWorkflowConditionProvider create(KeycloakSession session, Map<String,
1717
return new RoleWorkflowConditionProvider(session, config.get(EXPECTED_ROLES));
1818
}
1919

20+
@Override
21+
public RoleWorkflowConditionProvider create(KeycloakSession session, List<String> configParameters) {
22+
return new RoleWorkflowConditionProvider(session, configParameters);
23+
}
24+
2025
@Override
2126
public String getId() {
2227
return ID;

model/jpa/src/main/java/org/keycloak/models/workflow/conditions/UserAttributeWorkflowConditionFactory.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@ public UserAttributeWorkflowConditionProvider create(KeycloakSession session, Ma
1616
return new UserAttributeWorkflowConditionProvider(session, config);
1717
}
1818

19+
@Override
20+
public UserAttributeWorkflowConditionProvider create(KeycloakSession session, List<String> configParameters) {
21+
if (configParameters.size() % 2 != 0) {
22+
throw new IllegalArgumentException("Expected even number of configuration parameters (attribute key/value pairs)");
23+
}
24+
// Convert list of parameters into map of expected attributes
25+
Map<String, List<String>> expectedAttributes = new java.util.HashMap<>();
26+
for (int i = 0; i < configParameters.size(); i += 2) {
27+
String key = configParameters.get(i);
28+
String value = configParameters.get(i + 1);
29+
// value can have multiple values separated by comma
30+
List<String> values = List.of(value.split(","));
31+
expectedAttributes.put(key, values);
32+
}
33+
return create(session, expectedAttributes);
34+
}
35+
1936
@Override
2037
public String getId() {
2138
return ID;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package org.keycloak.models.workflow.conditions.expression;
2+
3+
import org.antlr.v4.runtime.tree.ParseTree;
4+
import org.keycloak.models.KeycloakSession;
5+
import org.keycloak.models.workflow.WorkflowConditionProvider;
6+
import org.keycloak.models.workflow.WorkflowConditionProviderFactory;
7+
import org.keycloak.models.workflow.WorkflowEvent;
8+
import org.keycloak.models.workflow.WorkflowsManager;
9+
10+
import java.util.List;
11+
import java.util.stream.Collectors;
12+
13+
public class BooleanConditionEvaluator extends BooleanConditionBaseVisitor<Boolean> {
14+
15+
private final KeycloakSession session;
16+
private final WorkflowEvent event;
17+
private final WorkflowsManager manager;
18+
19+
public BooleanConditionEvaluator(KeycloakSession session, WorkflowEvent event) {
20+
this.session = session;
21+
this.event = event;
22+
this.manager = new WorkflowsManager(session);
23+
}
24+
25+
@Override
26+
public Boolean visitEvaluator(BooleanConditionParser.EvaluatorContext ctx) {
27+
return visit(ctx.expression());
28+
}
29+
30+
@Override
31+
public Boolean visitExpression(BooleanConditionParser.ExpressionContext ctx) {
32+
if (ctx.expression() != null && ctx.OR() != null) {
33+
return visit(ctx.expression()) || visit(ctx.andExpression());
34+
}
35+
return visit(ctx.andExpression());
36+
}
37+
38+
@Override
39+
public Boolean visitAndExpression(BooleanConditionParser.AndExpressionContext ctx) {
40+
if (ctx.andExpression() != null && ctx.AND() != null) {
41+
return visit(ctx.andExpression()) && visit(ctx.notExpression());
42+
}
43+
return visit(ctx.notExpression());
44+
}
45+
46+
@Override
47+
public Boolean visitNotExpression(BooleanConditionParser.NotExpressionContext ctx) {
48+
if (ctx.NOT() != null) {
49+
return !visit(ctx.notExpression());
50+
}
51+
return visit(ctx.atom());
52+
}
53+
54+
@Override
55+
public Boolean visitAtom(BooleanConditionParser.AtomContext ctx) {
56+
if (ctx.conditionCall() != null) {
57+
return visit(ctx.conditionCall());
58+
}
59+
return visit(ctx.expression());
60+
}
61+
62+
@Override
63+
public Boolean visitConditionCall(BooleanConditionParser.ConditionCallContext ctx) {
64+
String conditionName = ctx.Identifier().getText();
65+
WorkflowConditionProviderFactory<WorkflowConditionProvider> providerFactory = manager.getConditionProviderFactory(conditionName);
66+
WorkflowConditionProvider conditionProvider = providerFactory.create(session, extractParameterList(ctx.parameterList()));
67+
return conditionProvider.evaluate(event);
68+
}
69+
70+
private List<String> extractParameterList(BooleanConditionParser.ParameterListContext ctx) {
71+
if (ctx == null) {
72+
return List.of();
73+
}
74+
return ctx.StringLiteral().stream()
75+
.map(this::visitStringLiteral)
76+
.collect(Collectors.toList());
77+
}
78+
79+
private String visitStringLiteral(ParseTree ctx) {
80+
String text = ctx.getText();
81+
return text.substring(1, text.length() - 1).replace("\"\"", "\"");
82+
}
83+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package org.keycloak.models.workflow.conditions.expression;
2+
3+
import org.antlr.v4.runtime.BaseErrorListener;
4+
import org.antlr.v4.runtime.RecognitionException;
5+
import org.antlr.v4.runtime.Recognizer;
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
9+
public class ErrorListener extends BaseErrorListener {
10+
private boolean hasErrors = false;
11+
private final List<String> errorMessages = new ArrayList<>();
12+
13+
@Override
14+
public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol,
15+
int line, int charPositionInLine,
16+
String msg, RecognitionException e) {
17+
hasErrors = true;
18+
String error = String.format("Error at line %d:%d - %s", line, charPositionInLine, msg);
19+
errorMessages.add(error);
20+
}
21+
22+
public boolean hasErrors() {
23+
return hasErrors;
24+
}
25+
26+
public List<String> getErrorMessages() {
27+
return errorMessages;
28+
}
29+
}

0 commit comments

Comments
 (0)