diff --git a/src/main/java/io/aiven/kafka/auth/AivenAclAuthorizerV2.java b/src/main/java/io/aiven/kafka/auth/AivenAclAuthorizerV2.java index 1bf1744..7e598be 100644 --- a/src/main/java/io/aiven/kafka/auth/AivenAclAuthorizerV2.java +++ b/src/main/java/io/aiven/kafka/auth/AivenAclAuthorizerV2.java @@ -192,7 +192,9 @@ public final List 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; diff --git a/src/main/java/io/aiven/kafka/auth/VerdictCache.java b/src/main/java/io/aiven/kafka/auth/VerdictCache.java index 8cbca3d..3009ffa 100644 --- a/src/main/java/io/aiven/kafka/auth/VerdictCache.java +++ b/src/main/java/io/aiven/kafka/auth/VerdictCache.java @@ -41,18 +41,22 @@ private VerdictCache(@Nonnull final List 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 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 { diff --git a/src/main/java/io/aiven/kafka/auth/json/AivenAcl.java b/src/main/java/io/aiven/kafka/auth/json/AivenAcl.java index d4e920b..98c60ef 100644 --- a/src/main/java/io/aiven/kafka/auth/json/AivenAcl.java +++ b/src/main/java/io/aiven/kafka/auth/json/AivenAcl.java @@ -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; @@ -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; @@ -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); @@ -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) { @@ -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) @@ -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() + + "'}"; + } } diff --git a/src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java b/src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java index c8caaa9..f580686 100644 --- a/src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java +++ b/src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java @@ -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; @@ -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, @@ -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); } diff --git a/src/test/java/io/aiven/kafka/auth/json/AivenAclTest.java b/src/test/java/io/aiven/kafka/auth/json/AivenAclTest.java index 7fbe9c7..f80235f 100644 --- a/src/test/java/io/aiven/kafka/auth/json/AivenAclTest.java +++ b/src/test/java/io/aiven/kafka/auth/json/AivenAclTest.java @@ -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_(?[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")); } } diff --git a/src/test/java/io/aiven/kafka/auth/json/reader/AclJsonReaderTest.java b/src/test/java/io/aiven/kafka/auth/json/reader/AclJsonReaderTest.java index 0d3990c..877e0aa 100644 --- a/src/test/java/io/aiven/kafka/auth/json/reader/AclJsonReaderTest.java +++ b/src/test/java/io/aiven/kafka/auth/json/reader/AclJsonReaderTest.java @@ -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) ); } @@ -65,6 +68,7 @@ public final void parseDenyAcl() { final var allowAcl = new AivenAcl( "User", "^allow$", + "*", "^Read$", "^(.*)$", null, @@ -73,6 +77,7 @@ public final void parseDenyAcl() { final var denyAcl = new AivenAcl( "User", "^deny$", + "*", "^Read$", "^(.*)$", null, diff --git a/src/test/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverterTest.java b/src/test/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverterTest.java index 2dbb305..ea0189b 100644 --- a/src/test/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverterTest.java +++ b/src/test/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverterTest.java @@ -40,6 +40,7 @@ public final void testConvertSimple() { new AivenAcl( "User", "^(test\\-user)$", + "*", "^(Alter|AlterConfigs|Delete|Read|Write)$", "^Topic:(xxx)$", null, @@ -72,6 +73,7 @@ public final void testNullPermissionTypeIsAllow() { new AivenAcl( "User", "^(test\\-user)$", + "*", "^Read$", "^Topic:(xxx)$", null, @@ -92,6 +94,7 @@ public final void testConvertPrefix() { new AivenAcl( "User", "^(test\\-user)$", + "*", "^Read$", "^Topic:(topic\\.(.*))$", null, @@ -112,6 +115,7 @@ public final void testDeny() { new AivenAcl( "User", "^(test\\-user)$", + "*", "^Read$", "^Topic:(topic\\.(.*))$", null, @@ -132,6 +136,7 @@ public final void testConvertMultiplePrefixes() { new AivenAcl( "User", "^(test\\-user)$", + "*", "^(Delete|Read|Write)$", "^Topic:(topic\\.(.*)|prefix\\-(.*))$", null, @@ -172,6 +177,7 @@ public final void testSuperadmin() { new AivenAcl( "User", "^(admin)$", + "*", "^(.*)$", "^(.*)$", null, @@ -205,6 +211,7 @@ public final void testAllUsers() { new AivenAcl( "User", "^(.*)$", + "*", "^Read$", "^Topic:(xxx)$", null, @@ -226,6 +233,7 @@ public final void testNoUserPrincipalType() { new AivenAcl( "Group", "^example$", + "*", "^Read$", "^Topic:(xxx)$", null, diff --git a/src/test/resources/acls_full.json b/src/test/resources/acls_full.json index 07c248e..cae9a1c 100644 --- a/src/test/resources/acls_full.json +++ b/src/test/resources/acls_full.json @@ -94,5 +94,19 @@ "principal": "^pass-resource-pattern$", "operation": "^Read$", "resource_pattern": "^Topic:${projectid}-(.*)" + }, + { + "principal_type": "User", + "principal": "^pass-13$", + "host": "*", + "operation": "^Read$", + "resource": "^Topic:(.*)$" + }, + { + "principal_type": "User", + "principal": "^pass-14$", + "host": "example.com", + "operation": "^Read$", + "resource": "^Topic:(.*)$" } -] \ No newline at end of file +] diff --git a/src/test/resources/acls_host_match.json b/src/test/resources/acls_host_match.json new file mode 100644 index 0000000..a5e9a29 --- /dev/null +++ b/src/test/resources/acls_host_match.json @@ -0,0 +1,24 @@ +[ + { + "principal_type": "User", + "principal": "^(.*)$", + "host": "*", + "operation": "^(.*)$", + "resource": "^Topic:topic-1$" + }, + { + "principal_type": "User", + "principal": "^(.*)$", + "host": "192.168.123.45", + "operation": "^(.*)$", + "resource": "^Topic:topic-2$" + }, + { + "principal_type": "User", + "principal": "^(.*)$", + "host": "192.168.123.47", + "operation": "^(.*)$", + "resource": "^Topic:topic-1$", + "permission_type": "DENY" + } +]