From 75360edb3ea2c6d0bbdc9d656659c7b034156c4f Mon Sep 17 00:00:00 2001 From: Francesco D'Orlandi Date: Thu, 7 Nov 2024 16:01:42 +0100 Subject: [PATCH] feat: add acl resource literal and prefixed matching --- README.md | 10 +- .../io/aiven/kafka/auth/json/AivenAcl.java | 82 +++++++++---- .../nativeacls/AclAivenToNativeConverter.java | 17 ++- .../nativeacls/ResourcePatternParser.java | 29 +++++ .../utils/ResourceLiteralWildcardMatcher.java | 38 ++++++ .../kafka/auth/AivenAclAuthorizerV2Test.java | 24 ++++ .../aiven/kafka/auth/json/AivenAclTest.java | 53 +++++++- .../auth/json/reader/AclJsonReaderTest.java | 68 +++++++---- .../AclAivenToNativeConverterTest.java | 115 ++++++++++++++++-- .../ResourceLiteralWildcardMatcherTest.java | 32 +++++ src/test/resources/acl_validation_fails.json | 8 ++ .../acls_resource_literal_match.json | 21 ++++ .../resources/acls_resource_prefix_match.json | 15 +++ 13 files changed, 444 insertions(+), 68 deletions(-) create mode 100644 src/main/java/io/aiven/kafka/auth/utils/ResourceLiteralWildcardMatcher.java create mode 100644 src/test/java/io/aiven/kafka/auth/utils/ResourceLiteralWildcardMatcherTest.java create mode 100644 src/test/resources/acl_validation_fails.json create mode 100644 src/test/resources/acls_resource_literal_match.json create mode 100644 src/test/resources/acls_resource_prefix_match.json diff --git a/README.md b/README.md index bda0571..48ed00c 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,12 @@ Class implementing a single ACL entry verification. Principal, operation and resource are expressed as regular expressions. Alternatively to straight regular expression for resource, AivenAclEntry can -be given a resource pattern with back references to principal regex. This is -used internally in Aiven to map project id from certificate subject into -project specific management topics. We can thus avoid encoding separate rules -for each project. +be given a resource pattern with back references to principal regex, a literal +match or a prefixed match. The first is used internally in Aiven to map project +id from certificate subject into project specific management topics. We can thus +avoid encoding separate rules for each project. Literal and prefixed matchers +work as defined in the Apache Kafka documentation. Only one resource matcher can be +specified per acl. Permission type allows to define the verification result in case of an ACL match. By default, the permission type is `ALLOW`. 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 5cc9c56..49d9435 100644 --- a/src/main/java/io/aiven/kafka/auth/json/AivenAcl.java +++ b/src/main/java/io/aiven/kafka/auth/json/AivenAcl.java @@ -20,6 +20,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import io.aiven.kafka.auth.utils.ResourceLiteralWildcardMatcher; + import com.google.gson.annotations.SerializedName; public class AivenAcl { @@ -41,6 +43,12 @@ public class AivenAcl { @SerializedName("resource_pattern") public final String resourceRePattern; + @SerializedName("resource_literal") + public final String resourceLiteral; + + @SerializedName("resource_prefix") + public final String resourcePrefix; + @SerializedName("permission_type") private final AclPermissionType permissionType; @@ -49,20 +57,26 @@ public class AivenAcl { private final boolean hidden; - public AivenAcl(final String principalType, - final String principal, - final String host, - final String operation, - final String resource, - final String resourcePattern, - final AclPermissionType permissionType, - final boolean hidden) { + public AivenAcl( + final String principalType, + final String principal, + final String host, + final String operation, + final String resource, + final String resourcePattern, + final String resourceLiteral, + final String resourcePrefix, + final AclPermissionType permissionType, + final boolean hidden + ) { 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; + this.resourceLiteral = resourceLiteral; + this.resourcePrefix = resourcePrefix; this.permissionType = Objects.requireNonNullElse(permissionType, AclPermissionType.ALLOW); this.hidden = hidden; } @@ -92,19 +106,7 @@ public Boolean match(final String principalType, 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() && this.hostMatch(host)) { - Matcher mr = null; - if (this.resourceRe != null) { - mr = this.resourceRe.matcher(resource); - } else if (this.resourceRePattern != null) { - final String resourceReStr = mp.replaceAll(this.resourceRePattern); - final Pattern resourceRe = Pattern.compile(resourceReStr); - mr = resourceRe.matcher(resource); - } - if (mr != null && mr.find()) { - return true; - } - } + return mp.find() && mo.find() && this.hostMatch(host) && this.resourceMatch(resource, mp); } return false; } @@ -114,6 +116,22 @@ private boolean hostMatch(final String host) { || getHostMatcher().equals(host); } + private boolean resourceMatch(final String resource, final Matcher principalBackreference) { + if (this.resourceRe != null) { + return this.resourceRe.matcher(resource).find(); + } else if (this.resourceRePattern != null) { + final String resourceReStr = principalBackreference.replaceAll(this.resourceRePattern); + final Pattern resourceRe = Pattern.compile(resourceReStr); + return resourceRe.matcher(resource).find(); + } else if (this.resourceLiteral != null) { + return ResourceLiteralWildcardMatcher.match(this.resourceLiteral, resource) + || this.resourceLiteral.equals(resource); + } else if (this.resourcePrefix != null) { + return resource != null && resource.startsWith(this.resourcePrefix); + } + return false; + } + @Override public boolean equals(final Object o) { if (this == o) { @@ -138,7 +156,9 @@ private boolean equalsPrincipal(final AivenAcl aivenAcl) { private boolean equalsResource(final AivenAcl aivenAcl) { return comparePattern(resourceRe, aivenAcl.resourceRe) - && Objects.equals(resourceRePattern, aivenAcl.resourceRePattern); + && Objects.equals(resourceRePattern, aivenAcl.resourceRePattern) + && Objects.equals(resourceLiteral, aivenAcl.resourceLiteral) + && Objects.equals(resourcePrefix, aivenAcl.resourcePrefix); } private boolean comparePattern(final Pattern p1, final Pattern p2) { @@ -157,7 +177,8 @@ private boolean comparePattern(final Pattern p1, final Pattern p2) { @Override public int hashCode() { return Objects.hash( - principalType, principalRe, hostMatcher, operationRe, resourceRe, resourceRePattern, getPermissionType() + principalType, principalRe, hostMatcher, operationRe, resourceRe, + resourceRePattern, resourceLiteral, resourcePrefix, getPermissionType() ); } @@ -167,14 +188,25 @@ public String toString() { + "principalType='" + principalType + "', principalRe=" + principalRe + ", operationRe=" + operationRe - + ", resourceRe=" + resourceRe - + ", resourceRePattern='" + resourceRePattern + + ", resource='" + getResourceString() + "', permissionType=" + getPermissionType() + ", hostMatcher='" + getHostMatcher() + ", hidden=" + hidden + "'}"; } + private String getResourceString() { + if (resourceRe != null) { + return resourceRe.toString(); + } else if (resourceRePattern != null) { + return resourceRePattern; + } else if (resourceLiteral != null) { + return resourceLiteral + " (LITERAL)"; + } else { + return resourcePrefix + " (PREFIXED)"; + } + } + public boolean isHidden() { return hidden; } diff --git a/src/main/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverter.java b/src/main/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverter.java index d4ef479..51a2e22 100644 --- a/src/main/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverter.java +++ b/src/main/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverter.java @@ -28,7 +28,8 @@ public class AclAivenToNativeConverter { public static List convert(final AivenAcl aivenAcl) { final List result = new ArrayList<>(); - if (aivenAcl.resourceRe == null) { + if (aivenAcl.resourceRePattern != null) { + // resourceRePattern are for internal rules, so don't return them return result; } @@ -43,8 +44,18 @@ public static List convert(final AivenAcl aivenAcl) { for (final var principal : principals) { final var accessControlEntry = new AccessControlEntry( principal, aivenAcl.getHostMatcher(), operation, aivenAcl.getPermissionType().nativeType); - for (final var resourcePattern : ResourcePatternParser.parse(aivenAcl.resourceRe.pattern())) { - result.add(new AclBinding(resourcePattern, accessControlEntry)); + if (aivenAcl.resourceRe != null) { + for (final var resourcePattern : ResourcePatternParser.parse(aivenAcl.resourceRe.pattern())) { + result.add(new AclBinding(resourcePattern, accessControlEntry)); + } + } else if (aivenAcl.resourceLiteral != null) { + ResourcePatternParser.parseLiteral(aivenAcl.resourceLiteral).ifPresent( + resourcePattern -> result.add(new AclBinding(resourcePattern, accessControlEntry)) + ); + } else if (aivenAcl.resourcePrefix != null) { + ResourcePatternParser.parsePrefixed(aivenAcl.resourcePrefix).ifPresent( + resourcePattern -> result.add(new AclBinding(resourcePattern, accessControlEntry)) + ); } } } diff --git a/src/main/java/io/aiven/kafka/auth/nativeacls/ResourcePatternParser.java b/src/main/java/io/aiven/kafka/auth/nativeacls/ResourcePatternParser.java index 9a6e4d8..8d3e426 100644 --- a/src/main/java/io/aiven/kafka/auth/nativeacls/ResourcePatternParser.java +++ b/src/main/java/io/aiven/kafka/auth/nativeacls/ResourcePatternParser.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -28,6 +29,8 @@ import org.apache.kafka.common.resource.ResourcePattern; import org.apache.kafka.common.resource.ResourceType; +import io.aiven.kafka.auth.nameformatters.ResourceTypeNameFormatter; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -80,6 +83,32 @@ static Iterable parse(final String resourcePattern) { return result; } + private static Optional parseSerializedResource( + final String resourcePattern, + final PatternType patternType + ) { + if (resourcePattern == null) { + return Optional.empty(); + } + final String[] parts = resourcePattern.split(":", 2); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + LOGGER.debug("Invalid format for resource literal '{}'", resourcePattern); + return Optional.empty(); + } + return Optional.of(new ResourcePattern( + ResourceTypeNameFormatter.format(parts[0]), + parts[1], + patternType)); + } + + public static Optional parseLiteral(final String resourcePattern) { + return parseSerializedResource(resourcePattern, PatternType.LITERAL); + } + + public static Optional parsePrefixed(final String resourcePattern) { + return parseSerializedResource(resourcePattern, PatternType.PREFIXED); + } + private static ResourcePattern createResourcePattern(final ResourceType resourceType, final String resource) { if (resource.equals(".*") || resource.equals("(.*)")) { return new ResourcePattern(resourceType, "*", PatternType.LITERAL); diff --git a/src/main/java/io/aiven/kafka/auth/utils/ResourceLiteralWildcardMatcher.java b/src/main/java/io/aiven/kafka/auth/utils/ResourceLiteralWildcardMatcher.java new file mode 100644 index 0000000..ab45aea --- /dev/null +++ b/src/main/java/io/aiven/kafka/auth/utils/ResourceLiteralWildcardMatcher.java @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Aiven Oy https://aiven.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.aiven.kafka.auth.utils; + +public class ResourceLiteralWildcardMatcher { + // Here "pattern" is something like "Topic:topic-1" or "Topic:*", where the second form is the + // wildcard matching. The wildcard match is a bit more difficult than comparing for just "*", because + // the prefix must be compared with the one of "resource". + public static boolean match(final String pattern, final String resource) { + if (pattern == null || resource == null) { + return false; + } + final int matchLength = Math.min(pattern.length(), resource.length()); + for (int i = 0; i < matchLength; i++) { + if (pattern.charAt(i) != resource.charAt(i)) { + return false; + } + if (pattern.charAt(i) == ':') { + return pattern.length() > i + 1 && pattern.charAt(i + 1) == '*'; + } + } + return false; + } +} diff --git a/src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java b/src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java index f580686..5929f8d 100644 --- a/src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java +++ b/src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java @@ -440,6 +440,30 @@ public void testHostMatcherWildcard() throws IOException { ); } + @Test + public void testResourceLiteralMatcher() throws IOException { + Files.copy(this.getClass().getResourceAsStream("/acls_resource_literal_match.json"), configFilePath); + startAuthorizer(); + checkSingleAction(requestCtx("User", "user1"), action(READ_OPERATION, topic("topic-1")), true); + checkSingleAction(requestCtx("User", "user1"), action(READ_OPERATION, topic("topic-2")), false); + checkSingleAction(requestCtx("User", "user2"), action(READ_OPERATION, topic("topic-2")), false); + checkSingleAction(requestCtx("User", "user3"), action(READ_OPERATION, topic("topic-3")), true); + } + + @Test + public void testResourcePrefixMatcher() throws IOException { + Files.copy(this.getClass().getResourceAsStream("/acls_resource_prefix_match.json"), configFilePath); + startAuthorizer(); + checkSingleAction(requestCtx("User", "user1"), action(READ_OPERATION, topic("groupA.topic")), true); + checkSingleAction(requestCtx("User", "user1"), action(READ_OPERATION, topic("groupC.topic")), false); + checkSingleAction(requestCtx("User", "user2"), action(READ_OPERATION, topic("groupB.topic")), false); + } + + + public ResourcePattern topic(final String name) { + return new ResourcePattern(ResourceType.TOPIC, name, PatternType.LITERAL); + } + private void startAuthorizer() { final AuthorizerServerInfo serverInfo = mock(AuthorizerServerInfo.class); when(serverInfo.endpoints()).thenReturn(List.of()); 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 3aec01b..23ce12e 100644 --- a/src/test/java/io/aiven/kafka/auth/json/AivenAclTest.java +++ b/src/test/java/io/aiven/kafka/auth/json/AivenAclTest.java @@ -32,7 +32,10 @@ public void testAivenAclEntry() { "^(Describe|Read)$", // operation "^Topic:p_(.*)_s", // resource, null, // resource pattern - null, false + null, // resource literal + null, // resource prefix + null, // permission type + false // hidden ); assertTrue(entry.match("User", "CN=p_pass_s", "*", "Read", "Topic:p_pass_s")); @@ -49,7 +52,10 @@ public void testAivenAclEntry() { "^(Describe|Read)$", // operation "^Topic:p_(.*)_s", // resource null, // resource pattern - null, false + null, // resource literal + null, // resource prefix + null, // permission type + false // hidden ); assertTrue(entry.match("User", "CN=p_pass_s", "*", "Read", "Topic:p_pass_s")); @@ -65,12 +71,53 @@ public void testAivenAclEntry() { "^(Describe|Read)$", // operation null, // resource "^Topic:p_${username}_s\\$", // resource pattern - null, false + null, // resource literal + null, // resource prefix + null, // permission type + false // hidden ); 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")); + + // Test resources defined by literal match + entry = new AivenAcl( + "User", // principal type + "^CN=p_(?[a-z0-9]+)_s$", // principal + "*", // host + "^(Describe|Read)$", // operation + null, // resource + null, // resource pattern + "Topic:^(][", // invalid regex just to show that the match is a literal string + null, // resource prefix + null, // permission type + false // hidden + ); + + assertTrue(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:^(][")); + assertFalse(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:wrong_topic")); + + + // Test resources defined by prefix match + entry = new AivenAcl( + "User", // principal type + "^CN=p_(?[a-z0-9]+)_s$", // principal + "*", // host + "^(Describe|Read)$", // operation + null, // resource + null, // resource pattern + null, // resource literal + "Topic:organizationA.", // invalid regex just to show that the match is a literal string + null, // permission type + false // hidden + ); + assertTrue(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:organizationA.topic1")); + assertTrue(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:organizationA.topic2")); + assertTrue(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:organizationA.")); + assertFalse(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:organizationB.topic1")); + assertFalse(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:organizationA")); + assertFalse(entry.match("User", "CN=p_user1_s", "*", "Read", "Topic:AAAorganizationA.")); } } 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 2ecd22f..8df554d 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,28 +36,42 @@ 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, false), - new AivenAcl("User", "^pass-0$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-1$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-2$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-3$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-4$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-5$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-6$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-7$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-8$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-9$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-10$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-11$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-12$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl(null, "^pass-notype$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl( - "User", "^pass-resource-pattern$", "*", "^Read$", - null, "^Topic:${projectid}-(.*)", AclPermissionType.ALLOW, false - ), - new AivenAcl("User", "^pass-13$", "*", "^Read$", "^Topic:(.*)$", null, AclPermissionType.ALLOW, false), - new AivenAcl("User", "^pass-14$", "example.com", "^Read$", "^Topic:(.*)$", - null, AclPermissionType.ALLOW, true) + new AivenAcl("User", "^pass-3$", "*", "^Read$", + "^Topic:denied$", null, null, null, AclPermissionType.DENY, false), + new AivenAcl("User", "^pass-0$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-1$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-2$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-3$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-4$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-5$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-6$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-7$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-8$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-9$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-10$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-11$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-12$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl(null, "^pass-notype$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-resource-pattern$", "*", "^Read$", + null, "^Topic:${projectid}-(.*)", null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-13$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, false), + new AivenAcl("User", "^pass-14$", "example.com", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW, true) ); } @@ -73,7 +87,10 @@ public final void parseDenyAcl() { "^Read$", "^(.*)$", null, - AclPermissionType.ALLOW, false + null, + null, + AclPermissionType.ALLOW, + false ); final var denyAcl = new AivenAcl( "User", @@ -82,7 +99,10 @@ public final void parseDenyAcl() { "^Read$", "^(.*)$", null, - AclPermissionType.DENY, false + null, + null, + AclPermissionType.DENY, + false ); assertThat(acls).containsExactly(allowAcl, allowAcl, allowAcl, denyAcl, denyAcl); } 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 d8368cc..113e5f9 100644 --- a/src/test/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverterTest.java +++ b/src/test/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverterTest.java @@ -44,7 +44,10 @@ public final void testConvertSimple() { "^(Alter|AlterConfigs|Delete|Read|Write)$", "^Topic:(xxx)$", null, - io.aiven.kafka.auth.json.AclPermissionType.ALLOW, false + null, + null, + io.aiven.kafka.auth.json.AclPermissionType.ALLOW, + false ) ); final ResourcePattern resourcePattern = new ResourcePattern(ResourceType.TOPIC, "xxx", PatternType.LITERAL); @@ -77,7 +80,10 @@ public final void testNullPermissionTypeIsAllow() { "^Read$", "^Topic:(xxx)$", null, - null, false + null, + null, + null, + false ) ); final ResourcePattern resourcePattern = new ResourcePattern(ResourceType.TOPIC, "xxx", PatternType.LITERAL); @@ -98,7 +104,10 @@ public final void testConvertPrefix() { "^Read$", "^Topic:(topic\\.(.*))$", null, - null, false + null, + null, + null, + false ) ); assertThat(result).containsExactly( @@ -119,7 +128,10 @@ public final void testDeny() { "^Read$", "^Topic:(topic\\.(.*))$", null, - io.aiven.kafka.auth.json.AclPermissionType.DENY, false + null, + null, + io.aiven.kafka.auth.json.AclPermissionType.DENY, + false ) ); assertThat(result).containsExactly( @@ -140,7 +152,10 @@ public final void testConvertMultiplePrefixes() { "^(Delete|Read|Write)$", "^Topic:(topic\\.(.*)|prefix\\-(.*))$", null, - null, false + null, + null, + null, + false ) ); assertThat(result).containsExactly( @@ -181,7 +196,10 @@ public final void testSuperadmin() { "^(.*)$", "^(.*)$", null, - null, false + null, + null, + null, + false ) ); @@ -207,7 +225,10 @@ public final void testAllUsers() { "^Read$", "^Topic:(xxx)$", null, - null, false + null, + null, + null, + false ) ); @@ -229,7 +250,10 @@ public final void testNoUserPrincipalType() { "^Read$", "^Topic:(xxx)$", null, - null, false + null, + null, + null, + false ) ); @@ -246,7 +270,10 @@ public final void testConvertHostMatcher() { "^Read$", "^Topic:(xxx)$", null, - null, false + null, + null, + null, + false ) ); @@ -257,4 +284,74 @@ public final void testConvertHostMatcher() { ) ); } + + @Test + public final void testConvertResourceRePattern() { + final var result = AclAivenToNativeConverter.convert( + new AivenAcl( + "Prune", + "^CN=(?[a-z0-9-]+),OU=(?n[0-9]+),O=(?[a-f0-9-]+),ST=vm$", + "12.34.56.78", + "^Read$", + null, + "^Topic:${projectid}-(.*)", + null, + null, + null, + false + ) + ); + + assertThat(result).isEmpty(); + } + + @Test + public final void testConvertResourceLiteral() { + final var result = AclAivenToNativeConverter.convert( + new AivenAcl( + "User", + "^(test\\-user)$", + "12.34.56.78", + "^Read$", + null, + null, + "Topic:some-topic-abcde", + null, + null, + false + ) + ); + + assertThat(result).containsExactly( + new AclBinding( + new ResourcePattern(ResourceType.TOPIC, "some-topic-abcde", PatternType.LITERAL), + new AccessControlEntry("User:test\\-user", "12.34.56.78", AclOperation.READ, AclPermissionType.ALLOW) + ) + ); + } + + @Test + public final void testConvertResourcePrefix() { + final var result = AclAivenToNativeConverter.convert( + new AivenAcl( + "User", + "^(test\\-user)$", + "12.34.56.78", + "^Read$", + null, + null, + null, + "Topic:prefixA.", + null, + false + ) + ); + + assertThat(result).containsExactly( + new AclBinding( + new ResourcePattern(ResourceType.TOPIC, "prefixA.", PatternType.PREFIXED), + new AccessControlEntry("User:test\\-user", "12.34.56.78", AclOperation.READ, AclPermissionType.ALLOW) + ) + ); + } } diff --git a/src/test/java/io/aiven/kafka/auth/utils/ResourceLiteralWildcardMatcherTest.java b/src/test/java/io/aiven/kafka/auth/utils/ResourceLiteralWildcardMatcherTest.java new file mode 100644 index 0000000..bb7718e --- /dev/null +++ b/src/test/java/io/aiven/kafka/auth/utils/ResourceLiteralWildcardMatcherTest.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Aiven Oy https://aiven.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.aiven.kafka.auth.utils; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ResourceLiteralWildcardMatcherTest { + + @Test + public void testMatcher() { + assertTrue(ResourceLiteralWildcardMatcher.match("Topic:*", "Topic:topic-1")); + assertFalse(ResourceLiteralWildcardMatcher.match("Group:*", "Topic:topic-1")); + assertFalse(ResourceLiteralWildcardMatcher.match("Topic:topic-1", "Topic:*")); + } +} diff --git a/src/test/resources/acl_validation_fails.json b/src/test/resources/acl_validation_fails.json new file mode 100644 index 0000000..7d99249 --- /dev/null +++ b/src/test/resources/acl_validation_fails.json @@ -0,0 +1,8 @@ +[ + { + "principal_type": "User", + "principal": "^pass-3$", + "operation": "^Read$", + "permission_type": "ALLOW" + } +] diff --git a/src/test/resources/acls_resource_literal_match.json b/src/test/resources/acls_resource_literal_match.json new file mode 100644 index 0000000..04d669c --- /dev/null +++ b/src/test/resources/acls_resource_literal_match.json @@ -0,0 +1,21 @@ +[ + { + "principal_type": "User", + "principal": "^(user1)$", + "operation": "^(.*)$", + "resource_literal": "Topic:topic-1" + }, + { + "principal_type": "User", + "principal": "^(user2)$", + "operation": "^(.*)$", + "resource_literal": "Topic:topic-2", + "permission_type": "DENY" + }, + { + "principal_type": "User", + "principal": "^(user3)$", + "operation": "^(.*)$", + "resource_literal": "Topic:*" + } +] diff --git a/src/test/resources/acls_resource_prefix_match.json b/src/test/resources/acls_resource_prefix_match.json new file mode 100644 index 0000000..23b235b --- /dev/null +++ b/src/test/resources/acls_resource_prefix_match.json @@ -0,0 +1,15 @@ +[ + { + "principal_type": "User", + "principal": "^(user1)$", + "operation": "^(.*)$", + "resource_prefix": "Topic:groupA." + }, + { + "principal_type": "User", + "principal": "^(user2)$", + "operation": "^(.*)$", + "resource_prefix": "Topic:groupB.", + "permission_type": "DENY" + } +]