Skip to content

Commit

Permalink
feat: add acl host matching
Browse files Browse the repository at this point in the history
  • Loading branch information
biggusdonzus committed Nov 5, 2024
1 parent 49ccda9 commit e07a4d7
Show file tree
Hide file tree
Showing 9 changed files with 170 additions and 39 deletions.
2 changes: 2 additions & 0 deletions src/main/java/io/aiven/kafka/auth/AivenAclAuthorizerV2.java
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ public final List<AuthorizationResult> authorize(final AuthorizableRequestContex
final String resourceToCheck =
LegacyResourceTypeNameFormatter.format(resourcePattern.resourceType())
+ ":" + resourcePattern.name();
final String host = requestContext.clientAddress().getHostAddress();
final boolean verdict = cacheReference.get().get(principal,
host,
LegacyOperationNameFormatter.format(operation),
resourceToCheck);
final var authResult = verdict ? AuthorizationResult.ALLOWED : AuthorizationResult.DENIED;
Expand Down
12 changes: 8 additions & 4 deletions src/main/java/io/aiven/kafka/auth/VerdictCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,22 @@ private VerdictCache(@Nonnull final List<AivenAcl> denyAclEntries, @Nonnull fina
this.allowAclEntries = allowAclEntries;
}

public boolean get(final KafkaPrincipal principal,
final String operation,
final String resource) {
public boolean get(
final KafkaPrincipal principal,
final String host,
final String operation,
final String resource
) {
final String principalType = principal.getPrincipalType();
final String cacheKey = resource
+ "|" + operation
+ "|" + host
+ "|" + principal.getName()
+ "|" + principalType;

return cache.computeIfAbsent(cacheKey, key -> {
final Predicate<AivenAcl> matcher = acl ->
acl.match(principalType, principal.getName(), operation, resource);
acl.match(principalType, principal.getName(), host, operation, resource);
if (denyAclEntries.stream().anyMatch(matcher)) {
return false;
} else {
Expand Down
38 changes: 36 additions & 2 deletions src/main/java/io/aiven/kafka/auth/json/AivenAcl.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
import com.google.gson.annotations.SerializedName;

public class AivenAcl {

private static final String WILDCARD_HOST = "*";

@SerializedName("principal_type")
public final String principalType;

Expand All @@ -41,14 +44,19 @@ public class AivenAcl {
@SerializedName("permission_type")
private final AclPermissionType permissionType;

@SerializedName("host")
private final String hostMatcher;

public AivenAcl(final String principalType,
final String principal,
final String host,
final String operation,
final String resource,
final String resourcePattern,
final AclPermissionType permissionType) {
this.principalType = principalType;
this.principalRe = Pattern.compile(principal);
this.hostMatcher = host;
this.operationRe = Pattern.compile(operation);
this.resourceRe = Objects.nonNull(resource) ? Pattern.compile(resource) : null;
this.resourceRePattern = resourcePattern;
Expand All @@ -63,17 +71,24 @@ public AclPermissionType getPermissionType() {
return permissionType == null ? AclPermissionType.ALLOW : permissionType;
}

public String getHostMatcher() {
// Same thing as getPermissionType(), host matching has been added later, and to be backward compatible
// we consider a missing "host" filed in the ACL json the same as WILDCARD_HOST ("*")
return hostMatcher == null ? WILDCARD_HOST : hostMatcher;
}

/**
* Check if request matches this rule.
*/
public Boolean match(final String principalType,
final String principal,
final String host,
final String operation,
final String resource) {
if (this.principalType == null || this.principalType.equals(principalType)) {
final Matcher mp = this.principalRe.matcher(principal);
final Matcher mo = this.operationRe.matcher(operation);
if (mp.find() && mo.find()) {
if (mp.find() && mo.find() && this.hostMatch(host)) {
Matcher mr = null;
if (this.resourceRe != null) {
mr = this.resourceRe.matcher(resource);
Expand All @@ -90,6 +105,11 @@ public Boolean match(final String principalType,
return false;
}

private boolean hostMatch(final String host) {
return getHostMatcher().equals(WILDCARD_HOST)
|| getHostMatcher().equals(host);
}

@Override
public boolean equals(final Object o) {
if (this == o) {
Expand All @@ -101,6 +121,7 @@ public boolean equals(final Object o) {
final AivenAcl aivenAcl = (AivenAcl) o;
return Objects.equals(principalType, aivenAcl.principalType)
&& comparePattern(principalRe, aivenAcl.principalRe)
&& getHostMatcher().equals(aivenAcl.getHostMatcher())
&& comparePattern(operationRe, aivenAcl.operationRe)
&& comparePattern(resourceRe, aivenAcl.resourceRe)
&& Objects.equals(resourceRePattern, aivenAcl.resourceRePattern)
Expand All @@ -123,7 +144,20 @@ private boolean comparePattern(final Pattern p1, final Pattern p2) {
@Override
public int hashCode() {
return Objects.hash(
principalType, principalRe, operationRe, resourceRe, resourceRePattern, getPermissionType()
principalType, principalRe, hostMatcher, operationRe, resourceRe, resourceRePattern, getPermissionType()
);
}

@Override
public String toString() {
return "AivenAcl{"
+ "principalType='" + principalType
+ "', principalRe=" + principalRe
+ ", operationRe=" + operationRe
+ ", resourceRe=" + resourceRe
+ ", resourceRePattern='" + resourceRePattern
+ "', permissionType=" + getPermissionType()
+ ", hostMatcher='" + getHostMatcher()
+ "'}";
}
}
41 changes: 39 additions & 2 deletions src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.nio.file.Files;
import java.nio.file.Path;
Expand Down Expand Up @@ -411,17 +412,49 @@ public void testStart() {
);
}

@Test
public void testHostMatcherWildcard() throws IOException {
Files.copy(this.getClass().getResourceAsStream("/acls_host_match.json"), configFilePath);
startAuthorizer();
final var topic1 = new ResourcePattern(ResourceType.TOPIC, "topic-1", PatternType.LITERAL);
final var topic2 = new ResourcePattern(ResourceType.TOPIC, "topic-2", PatternType.LITERAL);

// test host rule with wildcard
checkSingleAction(requestCtx("User", "user"), action(READ_OPERATION, topic1), true);
// test specific host rule with allowed host
checkSingleAction(
requestCtxWithHost("User", "user", Inet4Address.getByName("192.168.123.45")),
action(READ_OPERATION, topic2), true
);
// test specific host rule with not matching host
checkSingleAction(
requestCtxWithHost("User", "user", Inet4Address.getByName("192.168.123.46")),
action(READ_OPERATION, topic2),
false
);
// test specific host rule with specific denied host
checkSingleAction(
requestCtxWithHost("User", "user", Inet4Address.getByName("192.168.123.47")),
action(READ_OPERATION, topic1),
false
);
}

private void startAuthorizer() {
final AuthorizerServerInfo serverInfo = mock(AuthorizerServerInfo.class);
when(serverInfo.endpoints()).thenReturn(List.of());
auth.start(serverInfo);
}

private AuthorizableRequestContext requestCtx(final String principalType, final String name) {
private AuthorizableRequestContext requestCtxWithHost(
final String principalType,
final String name,
final InetAddress host
) {
return new RequestContext(
new RequestHeader(ApiKeys.METADATA, (short) 0, "some-client-id", 123),
"connection-id",
InetAddress.getLoopbackAddress(),
host,
new KafkaPrincipal(principalType, name),
new ListenerName("SSL"),
SecurityProtocol.SSL,
Expand All @@ -430,6 +463,10 @@ private AuthorizableRequestContext requestCtx(final String principalType, final
);
}

private AuthorizableRequestContext requestCtx(final String principalType, final String name) {
return requestCtxWithHost(principalType, name, InetAddress.getLoopbackAddress());
}

private Action action(final AclOperation operation, final ResourcePattern resource) {
return new Action(operation, resource, 0, true, true);
}
Expand Down
29 changes: 16 additions & 13 deletions src/test/java/io/aiven/kafka/auth/json/AivenAclTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,46 +28,49 @@ public void testAivenAclEntry() {
AivenAcl entry = new AivenAcl(
"User", // principal type
"^CN=p_(.*)_s$", // principal
"*", // host
"^(Describe|Read)$", // operation
"^Topic:p_(.*)_s", // resource,
null, // resource pattern
null
);

assertTrue(entry.match("User", "CN=p_pass_s", "Read", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=fail", "Read", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=p_pass_s", "Write", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=p_pass_s", "Read", "Topic:fail"));
assertFalse(entry.match("NonUser", "CN=p_pass_s", "Read", "Topic:p_pass_s"));
assertTrue(entry.match("User", "CN=p_pass_s", "*", "Read", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=fail", "*", "Read", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=p_pass_s", "*", "Write", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=p_pass_s", "*", "Read", "Topic:fail"));
assertFalse(entry.match("NonUser", "CN=p_pass_s", "*", "Read", "Topic:p_pass_s"));

// Test with principal undefined
entry = new AivenAcl(
null, // principal type
"^CN=p_(.*)_s$", // principal
"*", // host
"^(Describe|Read)$", // operation
"^Topic:p_(.*)_s", // resource
null, // resource pattern
null
);

assertTrue(entry.match("User", "CN=p_pass_s", "Read", "Topic:p_pass_s"));
assertTrue(entry.match("NonUser", "CN=p_pass_s", "Read", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=fail", "Read", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=p_pass_s", "Read", "Topic:fail"));
assertTrue(entry.match("User", "CN=p_pass_s", "*", "Read", "Topic:p_pass_s"));
assertTrue(entry.match("NonUser", "CN=p_pass_s", "*", "Read", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=fail", "*", "Read", "Topic:p_pass_s"));
assertFalse(entry.match("User", "CN=p_pass_s", "*", "Read", "Topic:fail"));

// Test resources defined by pattern
entry = new AivenAcl(
"User", // principal type
"^CN=p_(?<username>[a-z0-9]+)_s$", // principal
"*", // host
"^(Describe|Read)$", // operation
null, // resource
"^Topic:p_${username}_s\\$", // resource pattern
null
);

assertTrue(entry.match("User", "CN=p_user1_s", "Read", "Topic:p_user1_s"));
assertTrue(entry.match("User", "CN=p_user2_s", "Read", "Topic:p_user2_s"));
assertFalse(entry.match("User", "CN=p_user1_s", "Read", "Topic:p_user2_s"));
assertFalse(entry.match("User", "CN=p_user2_s", "Read", "Topic:p_user1_s"));
assertTrue(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:p_user1_s"));
assertTrue(entry.match("User", "CN=p_user2_s", "*", "Read", "Topic:p_user2_s"));
assertFalse(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:p_user2_s"));
assertFalse(entry.match("User", "CN=p_user2_s", "*", "Read", "Topic:p_user1_s"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,27 @@ public final void parseAcls() {
final var jsonReader = new AclJsonReader(path);
final var acls = jsonReader.read();
assertThat(acls).containsExactly(
new AivenAcl("User", "^pass-3$", "^Read$", "^Topic:denied$", null, AclPermissionType.DENY),
new AivenAcl("User", "^pass-0$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-1$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-2$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-3$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-4$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-5$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-6$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-7$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-8$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-9$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-10$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-11$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-12$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl(null, "^pass-notype$", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-3$", "*", "^Read$", "^Topic:denied$", null, AclPermissionType.DENY),
new AivenAcl("User", "^pass-0$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-1$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-2$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-3$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-4$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-5$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-6$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-7$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-8$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-9$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-10$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-11$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-12$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl(null, "^pass-notype$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl(
"User", "^pass-resource-pattern$", "^Read$", null, "^Topic:${projectid}-(.*)", AclPermissionType.ALLOW
)
"User", "^pass-resource-pattern$", "*", "^Read$",
null, "^Topic:${projectid}-(.*)", AclPermissionType.ALLOW
),
new AivenAcl("User", "^pass-13$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW),
new AivenAcl("User", "^pass-14$", "example.com", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW)
);
}

Expand All @@ -65,6 +68,7 @@ public final void parseDenyAcl() {
final var allowAcl = new AivenAcl(
"User",
"^allow$",
"*",
"^Read$",
"^(.*)$",
null,
Expand All @@ -73,6 +77,7 @@ public final void parseDenyAcl() {
final var denyAcl = new AivenAcl(
"User",
"^deny$",
"*",
"^Read$",
"^(.*)$",
null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public final void testConvertSimple() {
new AivenAcl(
"User",
"^(test\\-user)$",
"*",
"^(Alter|AlterConfigs|Delete|Read|Write)$",
"^Topic:(xxx)$",
null,
Expand Down Expand Up @@ -72,6 +73,7 @@ public final void testNullPermissionTypeIsAllow() {
new AivenAcl(
"User",
"^(test\\-user)$",
"*",
"^Read$",
"^Topic:(xxx)$",
null,
Expand All @@ -92,6 +94,7 @@ public final void testConvertPrefix() {
new AivenAcl(
"User",
"^(test\\-user)$",
"*",
"^Read$",
"^Topic:(topic\\.(.*))$",
null,
Expand All @@ -112,6 +115,7 @@ public final void testDeny() {
new AivenAcl(
"User",
"^(test\\-user)$",
"*",
"^Read$",
"^Topic:(topic\\.(.*))$",
null,
Expand All @@ -132,6 +136,7 @@ public final void testConvertMultiplePrefixes() {
new AivenAcl(
"User",
"^(test\\-user)$",
"*",
"^(Delete|Read|Write)$",
"^Topic:(topic\\.(.*)|prefix\\-(.*))$",
null,
Expand Down Expand Up @@ -172,6 +177,7 @@ public final void testSuperadmin() {
new AivenAcl(
"User",
"^(admin)$",
"*",
"^(.*)$",
"^(.*)$",
null,
Expand Down Expand Up @@ -205,6 +211,7 @@ public final void testAllUsers() {
new AivenAcl(
"User",
"^(.*)$",
"*",
"^Read$",
"^Topic:(xxx)$",
null,
Expand All @@ -226,6 +233,7 @@ public final void testNoUserPrincipalType() {
new AivenAcl(
"Group",
"^example$",
"*",
"^Read$",
"^Topic:(xxx)$",
null,
Expand Down
Loading

0 comments on commit e07a4d7

Please sign in to comment.