From 17fdce3351b0ad202c809ce0a4b93e2a2b6df885 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 | 85 ++++++++++++++----- .../kafka/auth/json/reader/AclJsonReader.java | 6 +- .../nativeacls/AclAivenToNativeConverter.java | 18 +++- .../nativeacls/ResourcePatternParser.java | 28 ++++++ .../utils/ResourceLiteralWildcardMatcher.java | 38 +++++++++ .../kafka/auth/AivenAclAuthorizerV2Test.java | 24 ++++++ .../aiven/kafka/auth/json/AivenAclTest.java | 48 ++++++++++- .../auth/json/reader/AclJsonReaderTest.java | 52 +++++++----- .../AclAivenToNativeConverterTest.java | 85 +++++++++++++++++++ .../ResourceLiteralWildcardMatcherTest.java | 32 +++++++ src/test/resources/acl_validation_fails.json | 8 ++ .../acls_resource_literal_match.json | 21 +++++ .../resources/acls_resource_prefix_match.json | 15 ++++ 14 files changed, 416 insertions(+), 54 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 5461393..fb224ff 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 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 aaab9a7..a4df576 100644 --- a/src/main/java/io/aiven/kafka/auth/json/AivenAcl.java +++ b/src/main/java/io/aiven/kafka/auth/json/AivenAcl.java @@ -19,6 +19,9 @@ import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; + +import io.aiven.kafka.auth.utils.ResourceLiteralWildcardMatcher; import com.google.gson.annotations.SerializedName; @@ -41,26 +44,39 @@ 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; @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) { + 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 + ) { 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.validate(); } public AclPermissionType getPermissionType() { @@ -88,19 +104,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; } @@ -110,6 +114,23 @@ private boolean hostMatch(final String host) { || getHostMatcher().equals(host); } + // split into separate function because of cyclomatic complexity check + 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) { @@ -118,13 +139,19 @@ public boolean equals(final Object o) { if (o == null || getClass() != o.getClass()) { return false; } - final AivenAcl aivenAcl = (AivenAcl) o; + return equals((AivenAcl) o); + // split for cyclomatic complexity check + } + + private boolean equals(final AivenAcl aivenAcl) { 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) + && Objects.equals(resourceLiteral, aivenAcl.resourceLiteral) + && Objects.equals(resourcePrefix, aivenAcl.resourcePrefix) && getPermissionType() == aivenAcl.getPermissionType(); // always compare permission type using getter } @@ -141,10 +168,22 @@ private boolean comparePattern(final Pattern p1, final Pattern p2) { return p1.toString().equals(p2.toString()); } + public void validate() { + // check that only one of resources fields is not null + // in the future we can add checks for other fields + final long resourceCount = Stream.of(resourceRe, resourceRePattern, resourceLiteral, resourcePrefix) + .filter(Objects::nonNull) + .count(); + if (resourceCount != 1) { + throw new IllegalArgumentException("Must specify exactly one \"resource\" for " + this); + } + } + @Override public int hashCode() { return Objects.hash( - principalType, principalRe, hostMatcher, operationRe, resourceRe, resourceRePattern, getPermissionType() + principalType, principalRe, hostMatcher, operationRe, resourceRe, + resourceRePattern, resourceLiteral, resourcePrefix, getPermissionType() ); } @@ -156,6 +195,8 @@ public String toString() { + ", operationRe=" + operationRe + ", resourceRe=" + resourceRe + ", resourceRePattern='" + resourceRePattern + + ", resourceLiteral='" + resourceLiteral + + ", resourcePrefix='" + resourcePrefix + "', permissionType=" + getPermissionType() + ", hostMatcher='" + getHostMatcher() + "'}"; diff --git a/src/main/java/io/aiven/kafka/auth/json/reader/AclJsonReader.java b/src/main/java/io/aiven/kafka/auth/json/reader/AclJsonReader.java index a800e8d..84472af 100644 --- a/src/main/java/io/aiven/kafka/auth/json/reader/AclJsonReader.java +++ b/src/main/java/io/aiven/kafka/auth/json/reader/AclJsonReader.java @@ -39,7 +39,11 @@ public AclJsonReader(final Path configPath) { public List read() { try (final Reader reader = Files.newBufferedReader(configFile)) { final java.lang.reflect.Type t = new TypeToken>() {}.getType(); - return gsonBuilder.create().fromJson(reader, t); + final List acls = gsonBuilder.create().fromJson(reader, t); + if (acls != null) { + acls.forEach(AivenAcl::validate); + } + return acls; } catch (final JsonSyntaxException | JsonIOException | IOException ex) { throw new JsonReaderException( String.format( 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..69e7553 100644 --- a/src/main/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverter.java +++ b/src/main/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverter.java @@ -24,11 +24,13 @@ import org.apache.kafka.common.acl.AclBinding; import io.aiven.kafka.auth.json.AivenAcl; +import org.apache.kafka.common.resource.ResourcePattern; 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 +45,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..c0b2a2e 100644 --- a/src/main/java/io/aiven/kafka/auth/nativeacls/ResourcePatternParser.java +++ b/src/main/java/io/aiven/kafka/auth/nativeacls/ResourcePatternParser.java @@ -19,11 +19,13 @@ 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; import java.util.stream.Collectors; +import io.aiven.kafka.auth.nameformatters.ResourceTypeNameFormatter; import org.apache.kafka.common.resource.PatternType; import org.apache.kafka.common.resource.ResourcePattern; import org.apache.kafka.common.resource.ResourceType; @@ -80,6 +82,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..2fcf186 --- /dev/null +++ b/src/main/java/io/aiven/kafka/auth/utils/ResourceLiteralWildcardMatcher.java @@ -0,0 +1,38 @@ +/* + * Copyright 2019 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 f80235f..da6fea9 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,9 @@ public void testAivenAclEntry() { "^(Describe|Read)$", // operation "^Topic:p_(.*)_s", // resource, null, // resource pattern - null + null, // resource literal + null, // resource prefix + null // permission type ); assertTrue(entry.match("User", "CN=p_pass_s", "*", "Read", "Topic:p_pass_s")); @@ -49,7 +51,9 @@ public void testAivenAclEntry() { "^(Describe|Read)$", // operation "^Topic:p_(.*)_s", // resource null, // resource pattern - null + null, // resource literal + null, // resource prefix + null // permission type ); assertTrue(entry.match("User", "CN=p_pass_s", "*", "Read", "Topic:p_pass_s")); @@ -65,12 +69,50 @@ public void testAivenAclEntry() { "^(Describe|Read)$", // operation null, // resource "^Topic:p_${username}_s\\$", // resource pattern - null + null, // resource literal + null, // resource prefix + null // permission type ); 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 + ); + + 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 + ); + 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 877e0aa..79fd1fc 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,27 +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-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) + new AivenAcl("User", "^pass-3$", "*", "^Read$", "^Topic:denied$", null, null, null, AclPermissionType.DENY), + new AivenAcl("User", "^pass-0$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-1$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-2$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-3$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-4$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-5$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-6$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-7$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-8$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-9$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-10$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-11$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-12$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl(null, "^pass-notype$", "*", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-resource-pattern$", "*", "^Read$", + null, "^Topic:${projectid}-(.*)", null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-13$", "*", "^Read$", "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW), + new AivenAcl("User", "^pass-14$", "example.com", "^Read$", + "^Topic:(.*)$", null, null, null, AclPermissionType.ALLOW) ); } @@ -72,6 +72,8 @@ public final void parseDenyAcl() { "^Read$", "^(.*)$", null, + null, + null, AclPermissionType.ALLOW ); final var denyAcl = new AivenAcl( @@ -81,6 +83,8 @@ public final void parseDenyAcl() { "^Read$", "^(.*)$", null, + null, + null, AclPermissionType.DENY ); assertThat(acls).containsExactly(allowAcl, allowAcl, allowAcl, denyAcl, denyAcl); @@ -93,4 +97,10 @@ public final void parseWrong() { assertThrows(JsonParseException.class, jsonReader::read); } + @Test + public final void parseValidateFails() { + final var path = new File(this.getClass().getResource("/acl_validation_fails.json").getPath()).toPath(); + final var jsonReader = new AclJsonReader(path); + assertThrows(IllegalArgumentException.class, jsonReader::read); + } } 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 da0cd2e..932eaaa 100644 --- a/src/test/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverterTest.java +++ b/src/test/java/io/aiven/kafka/auth/nativeacls/AclAivenToNativeConverterTest.java @@ -44,6 +44,8 @@ public final void testConvertSimple() { "^(Alter|AlterConfigs|Delete|Read|Write)$", "^Topic:(xxx)$", null, + null, + null, io.aiven.kafka.auth.json.AclPermissionType.ALLOW ) ); @@ -77,6 +79,8 @@ public final void testNullPermissionTypeIsAllow() { "^Read$", "^Topic:(xxx)$", null, + null, + null, null ) ); @@ -98,6 +102,8 @@ public final void testConvertPrefix() { "^Read$", "^Topic:(topic\\.(.*))$", null, + null, + null, null ) ); @@ -119,6 +125,8 @@ public final void testDeny() { "^Read$", "^Topic:(topic\\.(.*))$", null, + null, + null, io.aiven.kafka.auth.json.AclPermissionType.DENY ) ); @@ -140,6 +148,8 @@ public final void testConvertMultiplePrefixes() { "^(Delete|Read|Write)$", "^Topic:(topic\\.(.*)|prefix\\-(.*))$", null, + null, + null, null ) ); @@ -181,6 +191,8 @@ public final void testSuperadmin() { "^(.*)$", "^(.*)$", null, + null, + null, null ) ); @@ -215,6 +227,8 @@ public final void testAllUsers() { "^Read$", "^Topic:(xxx)$", null, + null, + null, null ) ); @@ -237,6 +251,8 @@ public final void testNoUserPrincipalType() { "^Read$", "^Topic:(xxx)$", null, + null, + null, null ) ); @@ -254,6 +270,8 @@ public final void testConvertHostMatcher() { "^Read$", "^Topic:(xxx)$", null, + null, + null, null ) ); @@ -265,4 +283,71 @@ 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 + ) + ); + + 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 + ) + ); + + 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 + ) + ); + + 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" + } +]