Skip to content

Commit 665ef2f

Browse files
authored
kroxylicious-api+runtime: Add SASL subject builder (kroxylicious#2898)
* kroxylicious-api+runtime: Add SASL subject builder * Subjects can arise as a result of SASL authentication. * We add a plugin interface, SaslSubjectBuilderService, following the established convention. * Because the Principal subtypes are open, the builder provide a plugin point for adding principals to the Subject which are unknown to the kroxylicious-api or -runtime modules. * We also want to allow the possilibity of using information looked up from external systems such as LDAP/ActiveDirectory or an OAuth token introspection endpoint. Thus the builder has an asynchronous return type. * The runtime adds a DefaultSubjectBuilder implementation which can be used for SASL-based Subject building. Signed-off-by: Tom Bentley <[email protected]>
1 parent 157e3c7 commit 665ef2f

File tree

18 files changed

+838
-0
lines changed

18 files changed

+838
-0
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
package io.kroxylicious.proxy.authentication;
8+
9+
public interface PrincipalFactory<P extends Principal> {
10+
P newPrincipal(String name);
11+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
package io.kroxylicious.proxy.authentication;
7+
8+
import java.util.Optional;
9+
import java.util.concurrent.CompletionStage;
10+
11+
import io.kroxylicious.proxy.tls.ClientTlsContext;
12+
13+
/**
14+
* <p>Builds a {@link Subject} based on information available from a successful SASL authentication.</p>
15+
*
16+
* <p>A {@code SaslSubjectBuilder} instance is constructed by a {@link SaslSubjectBuilderService}.</p>
17+
*
18+
* <p>A SASL-authenticating {@link io.kroxylicious.proxy.filter.Filter Filter}
19+
* <em>may</em> use a {@code SaslSubjectBuilder} in order to construct the
20+
* {@link Subject} with which it calls
21+
* {@link io.kroxylicious.proxy.filter.FilterContext#clientSaslAuthenticationSuccess(String, Subject)
22+
* FilterContext.clientSaslAuthenticationSuccess(String, Subject)}.
23+
* As such, {@code SaslSubjectBuilder} is an opt-in way of decoupling the building of Subjects
24+
* from the mechanism of SASL authentication.
25+
* SASL-authenticating filters are not obliged to use this abstraction.</p>
26+
*/
27+
public interface SaslSubjectBuilder {
28+
29+
/**
30+
* Returns an asynchronous result which completes with the {@code Subject} built
31+
* from the
32+
* @param context
33+
* @return
34+
*/
35+
CompletionStage<Subject> buildSaslSubject(SaslSubjectBuilder.Context context);
36+
37+
/**
38+
* The context that's passed to {@link #buildSaslSubject(Context)}.
39+
*/
40+
interface Context {
41+
/**
42+
* @return The TLS context for the client connection, or empty if the client connection is not TLS.
43+
*/
44+
Optional<ClientTlsContext> clientTlsContext();
45+
46+
/**
47+
* @return The SASL context for the client connection.
48+
*/
49+
ClientSaslContext clientSaslContext();
50+
}
51+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
package io.kroxylicious.proxy.authentication;
8+
9+
public interface SaslSubjectBuilderService<C> extends AutoCloseable {
10+
void initialize(C config);
11+
12+
SaslSubjectBuilder build();
13+
14+
default void close() {
15+
}
16+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
package io.kroxylicious.proxy.authentication;
8+
9+
public class UserFactory implements PrincipalFactory<User> {
10+
@Override
11+
public User newPrincipal(String name) {
12+
return new User(name);
13+
}
14+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#
2+
# Copyright Kroxylicious Authors.
3+
#
4+
# Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
#
6+
7+
io.kroxylicious.proxy.authentication.UserFactory

kroxylicious-runtime/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
<groupId>io.kroxylicious</groupId>
4040
<artifactId>kroxylicious-api</artifactId>
4141
</dependency>
42+
<dependency>
43+
<groupId>com.google.re2j</groupId>
44+
<artifactId>re2j</artifactId>
45+
</dependency>
4246

4347
<!-- project dependencies - test -->
4448
<dependency>
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
package io.kroxylicious.proxy.internal.subject;
8+
9+
import java.util.List;
10+
import java.util.Optional;
11+
import java.util.ServiceLoader;
12+
import java.util.function.Function;
13+
import java.util.stream.Collectors;
14+
import java.util.stream.Stream;
15+
16+
import com.fasterxml.jackson.annotation.JsonProperty;
17+
18+
import io.kroxylicious.proxy.authentication.PrincipalFactory;
19+
import io.kroxylicious.proxy.authentication.SaslSubjectBuilder;
20+
import io.kroxylicious.proxy.authentication.SaslSubjectBuilderService;
21+
import io.kroxylicious.proxy.plugin.Plugin;
22+
import io.kroxylicious.proxy.plugin.Plugins;
23+
24+
import edu.umd.cs.findbugs.annotations.NonNull;
25+
import edu.umd.cs.findbugs.annotations.Nullable;
26+
27+
@Plugin(configType = DefaultSaslSubjectBuilderService.Config.class)
28+
public class DefaultSaslSubjectBuilderService implements SaslSubjectBuilderService<DefaultSaslSubjectBuilderService.Config> {
29+
30+
public static final String SASL_AUTHORIZED_ID = "saslAuthorizedId";
31+
public static final String ELSE_IDENTITY = "identity";
32+
public static final String ELSE_ANONYMOUS = "anonymous";
33+
34+
/*
35+
* subjectBuilder:
36+
* - type: DefaultSubjectBuilder
37+
* config:
38+
* addPrincipals:
39+
* - from: clientTlsSubject # a singleton or optional
40+
* map:
41+
* - sedLike: #CN=(.*?),.*#$1#
42+
* - else: identity
43+
* principalFactory: UserFactory
44+
* - from: clientTlsSubject
45+
* map:
46+
* - sedLike: #.*,OU=(.*?).*#$1#
47+
* - else: anonymous
48+
* principalFactory: RoleFactory
49+
* - from: LdapMemerOf # multi valued
50+
* map:
51+
* - sedLike: #.*,OU=(.*?).*#$1#
52+
* - else: anonymous
53+
*/
54+
public record Config(List<PrincipalAdderConf> addPrincipals) {
55+
56+
}
57+
58+
/**
59+
* Configuration for a principal adder, which is responsible for contributing zero or more principals to the subject.
60+
* @param from Names a function for extracting a string value from a {@link SaslSubjectBuilder.Context}.
61+
* @param map An optional list of mappings to apply to the `from`-extracted string.
62+
* @param principalFactory The name of a {@link PrincipalFactory} implementation class.
63+
*/
64+
public record PrincipalAdderConf(@JsonProperty(required = true) String from,
65+
@Nullable List<Map> map,
66+
@JsonProperty(required = true) String principalFactory) {
67+
public PrincipalAdderConf {
68+
// call methods for validation side-effect
69+
buildExtractor(from);
70+
buildMappingRules(map);
71+
buildPrincipalFactory(principalFactory);
72+
}
73+
}
74+
75+
record Map(@Nullable String replaceMatch,
76+
@JsonProperty("else") @Nullable String else_) {
77+
Map {
78+
if (replaceMatch != null) {
79+
if (else_ != null) {
80+
throw new IllegalArgumentException("`replaceMatch` and `else` are mutually exclusive.");
81+
}
82+
new ReplaceMatchMappingRule(replaceMatch);
83+
}
84+
else if (else_ == null) {
85+
throw new IllegalArgumentException("Exactly one of `replaceMatch` and `else` are required.");
86+
}
87+
else if (!else_.equals(ELSE_IDENTITY)
88+
&& !else_.equals(ELSE_ANONYMOUS)) {
89+
throw new IllegalArgumentException("`else` can only take the value 'identity' or 'anonymous'.");
90+
}
91+
}
92+
}
93+
94+
List<PrincipalAdder> adders;
95+
96+
@Override
97+
public void initialize(Config config) {
98+
adders = Plugins.requireConfig(this, config).addPrincipals().stream()
99+
.map(addConf -> new PrincipalAdder(buildExtractor(addConf.from()),
100+
buildMappingRules(addConf.map()),
101+
buildPrincipalFactory(addConf.principalFactory())))
102+
.toList();
103+
}
104+
105+
private static PrincipalFactory<?> buildPrincipalFactory(String principalFactory) {
106+
return ServiceLoader.load(PrincipalFactory.class).stream()
107+
.filter(provider -> provider.type().getName().equals(principalFactory))
108+
.findFirst()
109+
.orElseThrow(() -> new IllegalArgumentException("`principalFactory` '%s' not found.".formatted(principalFactory)))
110+
.get();
111+
}
112+
113+
@NonNull
114+
private static List<MappingRule> buildMappingRules(List<Map> maps) {
115+
if (maps == null || maps.isEmpty()) {
116+
return List.of(new IdentityMappingRule());
117+
}
118+
int firstElseIndex = -1;
119+
int numElses = 0;
120+
for (int i = 0; i < maps.size(); i++) {
121+
Map m = maps.get(i);
122+
123+
if (m.else_() != null) {
124+
numElses++;
125+
if (firstElseIndex == -1) {
126+
firstElseIndex = i;
127+
}
128+
}
129+
}
130+
if (numElses > 1) {
131+
throw new IllegalArgumentException("An `else` mapping may only occur at most once, as the last element of `map`.");
132+
}
133+
else if (firstElseIndex != -1 && firstElseIndex < maps.size() - 1) {
134+
throw new IllegalArgumentException("An `else` mapping may only occur as the last element of `map`.");
135+
}
136+
return maps.stream().map(DefaultSaslSubjectBuilderService::buildMappingRule).toList();
137+
}
138+
139+
@NonNull
140+
private static MappingRule buildMappingRule(Map map) {
141+
if (map.replaceMatch() != null) {
142+
return new ReplaceMatchMappingRule(map.replaceMatch());
143+
}
144+
else if (ELSE_IDENTITY.equals(map.else_())) {
145+
return new IdentityMappingRule();
146+
}
147+
else if (ELSE_ANONYMOUS.equals(map.else_())) {
148+
return s -> Optional.empty();
149+
}
150+
else {
151+
throw new IllegalArgumentException("Unknown `else` map '%s', supported values are: '%s', '%s'."
152+
.formatted(map.else_(), ELSE_IDENTITY, ELSE_ANONYMOUS));
153+
}
154+
}
155+
156+
@NonNull
157+
private static Function<Object, Stream<String>> buildExtractor(String from) {
158+
return switch (from) {
159+
case SASL_AUTHORIZED_ID -> context -> Stream.of(((SaslSubjectBuilder.Context) context).clientSaslContext().authorizationId());
160+
default -> throw new IllegalArgumentException("Unknown `from` '%s', supported values are: %s."
161+
.formatted(from,
162+
Stream.of(SASL_AUTHORIZED_ID).map(s -> '\'' + s + '\'')
163+
.collect(Collectors.joining(", "))));
164+
};
165+
}
166+
167+
@Override
168+
public SaslSubjectBuilder build() {
169+
return new DefaultSubjectBuilder(adders);
170+
}
171+
172+
@Override
173+
public void close() {
174+
// We have no closeable resources
175+
}
176+
177+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
package io.kroxylicious.proxy.internal.subject;
8+
9+
import java.util.List;
10+
import java.util.Set;
11+
import java.util.concurrent.CompletableFuture;
12+
import java.util.concurrent.CompletionStage;
13+
import java.util.stream.Collectors;
14+
15+
import io.kroxylicious.proxy.authentication.Principal;
16+
import io.kroxylicious.proxy.authentication.SaslSubjectBuilder;
17+
import io.kroxylicious.proxy.authentication.Subject;
18+
19+
public class DefaultSubjectBuilder implements SaslSubjectBuilder {
20+
private final List<PrincipalAdder> adders;
21+
22+
public DefaultSubjectBuilder(List<PrincipalAdder> adders) {
23+
this.adders = adders;
24+
}
25+
26+
@Override
27+
public CompletionStage<Subject> buildSaslSubject(SaslSubjectBuilder.Context context) {
28+
try {
29+
Set<Principal> collect = adders.stream()
30+
.flatMap(lal -> lal.createPrincipals(context))
31+
.collect(Collectors.toSet());
32+
return CompletableFuture.completedStage(new Subject(collect));
33+
}
34+
catch (Exception e) {
35+
return CompletableFuture.failedStage(e);
36+
}
37+
}
38+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
package io.kroxylicious.proxy.internal.subject;
8+
9+
import java.util.Optional;
10+
11+
public class IdentityMappingRule implements MappingRule {
12+
@Override
13+
public Optional<String> apply(String s) {
14+
return Optional.of(s);
15+
}
16+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright Kroxylicious Authors.
3+
*
4+
* Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
package io.kroxylicious.proxy.internal.subject;
8+
9+
import java.util.Optional;
10+
import java.util.function.Function;
11+
12+
public interface MappingRule extends Function<String, Optional<String>> {
13+
14+
}

0 commit comments

Comments
 (0)