This is an automated email from the ASF dual-hosted git repository.
mimaison pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git
The following commit(s) were added to refs/heads/trunk by this push:
new 82b804fe272 KAFKA-20088: CIDR-based Host Patterns for ACLs (#22501)
82b804fe272 is described below
commit 82b804fe27252816e05cf9360d27f6e743592621
Author: Maros Orsak <[email protected]>
AuthorDate: Thu Jul 9 16:11:24 2026 +0200
KAFKA-20088: CIDR-based Host Patterns for ACLs (#22501)
Implements KIP-1276.
Signed-off-by: see-quick <[email protected]>
Reviewers: Mickael Maison <[email protected]>, Luke Chen
<[email protected]>
---
LICENSE-binary | 1 +
build.gradle | 4 +
checkstyle/import-control-metadata.xml | 1 +
gradle/dependencies.gradle | 2 +
.../apache/kafka/controller/AclControlManager.java | 48 +++++-
.../apache/kafka/controller/QuorumController.java | 2 +-
.../kafka/metadata/authorizer/CidrUtils.java | 63 +++++++
.../authorizer/StandardAuthorizerData.java | 2 +-
.../kafka/controller/AclControlManagerTest.java | 121 ++++++++++++--
.../kafka/metadata/authorizer/CidrUtilsTest.java | 112 +++++++++++++
.../authorizer/StandardAuthorizerTest.java | 182 +++++++++++++++++++++
.../apache/kafka/controller/MockAclMutator.java | 3 +-
.../kafka/server/common/MetadataVersion.java | 8 +-
.../apache/kafka/common/test/api/ClusterTest.java | 2 +-
14 files changed, 530 insertions(+), 21 deletions(-)
diff --git a/LICENSE-binary b/LICENSE-binary
index cb2e37445e8..bec83923222 100644
--- a/LICENSE-binary
+++ b/LICENSE-binary
@@ -210,6 +210,7 @@ License Version 2.0:
- commons-collections-3.2.2
- commons-digester-2.1
- commons-logging-1.3.5
+- commons-net-3.13.0
- commons-validator-1.10.1
- hash4j-0.22.0
- jackson-annotations-2.21
diff --git a/build.gradle b/build.gradle
index 970f527a524..1bd566ccee4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1462,6 +1462,10 @@ project(':metadata') {
implementation project(':server-common')
implementation project(':clients')
implementation project(':raft')
+ // commons-io is a transitive dependency not needed by
SubnetUtils/SubnetUtils6
+ implementation(libs.commonsNet) {
+ exclude group: 'commons-io', module: 'commons-io'
+ }
implementation libs.jacksonDatabind
implementation libs.jacksonJDK8Datatypes
implementation libs.metrics
diff --git a/checkstyle/import-control-metadata.xml
b/checkstyle/import-control-metadata.xml
index cdc73dcf65d..1ee3207de44 100644
--- a/checkstyle/import-control-metadata.xml
+++ b/checkstyle/import-control-metadata.xml
@@ -168,6 +168,7 @@
<allow pkg="org.apache.kafka.common.metrics" />
<allow pkg="org.apache.kafka.common.metrics.internals" />
<allow pkg="org.apache.kafka.common.metrics.stats" />
+ <allow pkg="org.apache.commons.net.util" />
</subpackage>
<subpackage name="bootstrap">
<allow pkg="org.apache.kafka.snapshot" />
diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle
index 2e1105702f4..7a4aefd1751 100644
--- a/gradle/dependencies.gradle
+++ b/gradle/dependencies.gradle
@@ -58,6 +58,7 @@ versions += [
bndlib: "7.1.0",
checkstyle: project.hasProperty('checkstyleVersion') ? checkstyleVersion :
"12.3.1",
commonsValidator: "1.10.1",
+ commonsNet: "3.13.0",
classgraph: "4.8.179",
gradle: "9.4.1",
grgit: "5.3.3",
@@ -158,6 +159,7 @@ libs += [
bndlib:"biz.aQute.bnd:biz.aQute.bndlib:$versions.bndlib",
caffeine: "com.github.ben-manes.caffeine:caffeine:$versions.caffeine",
classgraph: "io.github.classgraph:classgraph:$versions.classgraph",
+ commonsNet: "commons-net:commons-net:$versions.commonsNet",
commonsValidator:
"commons-validator:commons-validator:$versions.commonsValidator",
jacksonAnnotations:
"com.fasterxml.jackson.core:jackson-annotations:$versions.jacksonAnnotations",
jacksonDatabind:
"com.fasterxml.jackson.core:jackson-databind:$versions.jackson",
diff --git
a/metadata/src/main/java/org/apache/kafka/controller/AclControlManager.java
b/metadata/src/main/java/org/apache/kafka/controller/AclControlManager.java
index c3ce52870db..22808ced18d 100644
--- a/metadata/src/main/java/org/apache/kafka/controller/AclControlManager.java
+++ b/metadata/src/main/java/org/apache/kafka/controller/AclControlManager.java
@@ -23,16 +23,19 @@ import org.apache.kafka.common.acl.AclBindingFilter;
import org.apache.kafka.common.errors.ApiException;
import org.apache.kafka.common.errors.InvalidRequestException;
import org.apache.kafka.common.errors.UnknownServerException;
+import org.apache.kafka.common.errors.UnsupportedVersionException;
import org.apache.kafka.common.metadata.AccessControlEntryRecord;
import org.apache.kafka.common.metadata.RemoveAccessControlEntryRecord;
import org.apache.kafka.common.requests.ApiError;
import org.apache.kafka.common.utils.internals.LogContext;
+import org.apache.kafka.metadata.authorizer.CidrUtils;
import org.apache.kafka.metadata.authorizer.StandardAcl;
import org.apache.kafka.metadata.authorizer.StandardAclWithId;
import org.apache.kafka.server.authorizer.AclCreateResult;
import org.apache.kafka.server.authorizer.AclDeleteResult;
import
org.apache.kafka.server.authorizer.AclDeleteResult.AclBindingDeleteResult;
import org.apache.kafka.server.common.ApiMessageAndVersion;
+import org.apache.kafka.server.common.MetadataVersion;
import org.apache.kafka.server.mutable.BoundedList;
import org.apache.kafka.server.mutable.BoundedListTooLongException;
import org.apache.kafka.timeline.SnapshotRegistry;
@@ -92,14 +95,14 @@ public class AclControlManager {
this.existingAcls = new TimelineHashSet<>(snapshotRegistry, 0);
}
- ControllerResult<List<AclCreateResult>> createAcls(List<AclBinding> acls) {
+ ControllerResult<List<AclCreateResult>> createAcls(List<AclBinding> acls,
MetadataVersion metadataVersion) {
Set<StandardAcl> aclsToCreate = new HashSet<>(acls.size());
List<AclCreateResult> results = new ArrayList<>(acls.size());
List<ApiMessageAndVersion> records =
BoundedList.newArrayBacked(MAX_RECORDS_PER_USER_OP);
for (AclBinding acl : acls) {
try {
- validateNewAcl(acl);
+ validateNewAcl(acl, metadataVersion.isCidrAclSupported());
} catch (Throwable t) {
ApiException e = (t instanceof ApiException) ? (ApiException)
t :
new UnknownServerException("Unknown error while trying to
create ACL", t);
@@ -132,7 +135,7 @@ public class AclControlManager {
return uuid;
}
- static void validateNewAcl(AclBinding binding) {
+ static void validateNewAcl(AclBinding binding, boolean isCidrAclSupported)
{
switch (binding.pattern().resourceType()) {
case UNKNOWN:
case ANY:
@@ -174,6 +177,45 @@ public class AclControlManager {
binding.entry().principal() + "` " + "(no colon is present
separating the " +
"principal type from the principal name)");
}
+ validateHostPattern(binding.entry().host(), isCidrAclSupported);
+ }
+
+ /**
+ * Validates the host pattern of an ACL entry.
+ *
+ * Accepts:
+ * - Wildcard "*" (matches any host)
+ * - Valid IPv4 address (e.g., "192.168.1.1")
+ * - Valid IPv6 address (e.g., "2001:db8::1")
+ * - Valid IPv4 CIDR notation (e.g., "192.168.0.0/24"), which requires
cidrSupported=true
+ * - Valid IPv6 CIDR notation (e.g., "2001:db8::/32"), which requires
cidrSupported=true
+ *
+ * @param host The host pattern to validate
+ * @param isCidrSupported Whether CIDR notation is supported by the
current metadata version
+ * @throws InvalidRequestException if the host pattern is invalid
+ * @throws UnsupportedVersionException if CIDR notation is used but not
supported
+ */
+ static void validateHostPattern(String host, boolean isCidrSupported) {
+ if (host == null || host.isEmpty()) {
+ throw new InvalidRequestException("Host pattern cannot be null or
empty");
+ }
+
+ if ("*".equals(host)) {
+ return;
+ }
+
+ if (host.contains("/")) {
+ if (!isCidrSupported) {
+ throw new UnsupportedVersionException(
+ "CIDR-based ACL host patterns require metadata version " +
+ MetadataVersion.IBP_4_4_IV1 + " or higher.");
+ }
+ try {
+ CidrUtils.validate(host);
+ } catch (IllegalArgumentException e) {
+ throw new InvalidRequestException("Invalid CIDR notation '" +
host + "': " + e.getMessage());
+ }
+ }
}
ControllerResult<List<AclDeleteResult>> deleteAcls(List<AclBindingFilter>
filters) {
diff --git
a/metadata/src/main/java/org/apache/kafka/controller/QuorumController.java
b/metadata/src/main/java/org/apache/kafka/controller/QuorumController.java
index 5da6eb2e3aa..6620c7e6dc9 100644
--- a/metadata/src/main/java/org/apache/kafka/controller/QuorumController.java
+++ b/metadata/src/main/java/org/apache/kafka/controller/QuorumController.java
@@ -2159,7 +2159,7 @@ public final class QuorumController implements Controller
{
List<AclBinding> aclBindings
) {
return appendWriteEvent("createAcls", context.deadlineNs(),
- () -> aclControlManager.createAcls(aclBindings));
+ () -> aclControlManager.createAcls(aclBindings,
featureControl.metadataVersionOrThrow()));
}
@Override
diff --git
a/metadata/src/main/java/org/apache/kafka/metadata/authorizer/CidrUtils.java
b/metadata/src/main/java/org/apache/kafka/metadata/authorizer/CidrUtils.java
new file mode 100644
index 00000000000..318b2c9bff0
--- /dev/null
+++ b/metadata/src/main/java/org/apache/kafka/metadata/authorizer/CidrUtils.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.metadata.authorizer;
+
+import org.apache.commons.net.util.SubnetUtils;
+import org.apache.commons.net.util.SubnetUtils6;
+
+public final class CidrUtils {
+
+ private CidrUtils() {}
+
+ private static boolean isIpv6(String cidrPattern) {
+ return cidrPattern.contains(":");
+ }
+
+ /**
+ * Validates a CIDR pattern by parsing it and constructing the appropriate
SubnetUtils.
+ * Throws {@link IllegalArgumentException} if the pattern is invalid.
+ */
+ public static void validate(String cidrPattern) {
+ if (isIpv6(cidrPattern)) {
+ new SubnetUtils6(cidrPattern);
+ } else {
+ new SubnetUtils(cidrPattern);
+ }
+ }
+
+ /**
+ * Checks whether a host IP address falls within a CIDR range.
+ * Returns false if the pattern is null, not a CIDR notation, or invalid.
+ */
+ public static boolean isInRange(String host, String cidrPattern) {
+ if (cidrPattern == null || !cidrPattern.contains("/")) {
+ return false;
+ }
+ try {
+ if (isIpv6(cidrPattern)) {
+ return new SubnetUtils6(cidrPattern).getInfo().isInRange(host);
+ } else {
+ SubnetUtils subnet = new SubnetUtils(cidrPattern);
+ subnet.setInclusiveHostCount(true);
+ return subnet.getInfo().isInRange(host);
+ }
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+}
diff --git
a/metadata/src/main/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerData.java
b/metadata/src/main/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerData.java
index e10b73277c1..f56c39dc379 100644
---
a/metadata/src/main/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerData.java
+++
b/metadata/src/main/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerData.java
@@ -489,7 +489,7 @@ public class StandardAuthorizerData {
return null;
}
// Check if the host matches. If it doesn't, return no result (null).
- if (!acl.host().equals(WILDCARD) && !acl.host().equals(host)) {
+ if (!acl.host().equals(WILDCARD) && !acl.host().equals(host) &&
!CidrUtils.isInRange(host, acl.host())) {
return null;
}
// Check if the operation field matches. Here we hit a slight
complication.
diff --git
a/metadata/src/test/java/org/apache/kafka/controller/AclControlManagerTest.java
b/metadata/src/test/java/org/apache/kafka/controller/AclControlManagerTest.java
index 73d910dfa44..5982f865c93 100644
---
a/metadata/src/test/java/org/apache/kafka/controller/AclControlManagerTest.java
+++
b/metadata/src/test/java/org/apache/kafka/controller/AclControlManagerTest.java
@@ -27,6 +27,7 @@ import org.apache.kafka.common.acl.AclOperation;
import org.apache.kafka.common.acl.AclPermissionType;
import org.apache.kafka.common.errors.InvalidRequestException;
import org.apache.kafka.common.errors.NotControllerException;
+import org.apache.kafka.common.errors.UnsupportedVersionException;
import org.apache.kafka.common.metadata.AccessControlEntryRecord;
import org.apache.kafka.common.metadata.RemoveAccessControlEntryRecord;
import org.apache.kafka.common.resource.PatternType;
@@ -49,6 +50,7 @@ import
org.apache.kafka.server.authorizer.AuthorizableRequestContext;
import org.apache.kafka.server.authorizer.AuthorizationResult;
import org.apache.kafka.server.authorizer.AuthorizerServerInfo;
import org.apache.kafka.server.common.ApiMessageAndVersion;
+import org.apache.kafka.server.common.MetadataVersion;
import org.apache.kafka.server.mutable.BoundedListTooLongException;
import org.apache.kafka.timeline.SnapshotRegistry;
@@ -88,30 +90,30 @@ public class AclControlManagerTest {
public void testValidateNewAcl() {
AclControlManager.validateNewAcl(new AclBinding(
new ResourcePattern(TOPIC, "*", LITERAL),
- new AccessControlEntry("User:*", "*", ALTER, ALLOW)));
+ new AccessControlEntry("User:*", "*", ALTER, ALLOW)), false);
assertEquals("Invalid patternType UNKNOWN",
assertThrows(InvalidRequestException.class, () ->
AclControlManager.validateNewAcl(new AclBinding(
new ResourcePattern(TOPIC, "*", PatternType.UNKNOWN),
- new AccessControlEntry("User:*", "*", ALTER, ALLOW)))).
+ new AccessControlEntry("User:*", "*", ALTER, ALLOW)),
false)).
getMessage());
assertEquals("Invalid resourceType UNKNOWN",
assertThrows(InvalidRequestException.class, () ->
AclControlManager.validateNewAcl(new AclBinding(
new ResourcePattern(ResourceType.UNKNOWN, "*", LITERAL),
- new AccessControlEntry("User:*", "*", ALTER, ALLOW)))).
+ new AccessControlEntry("User:*", "*", ALTER, ALLOW)),
false)).
getMessage());
assertEquals("Invalid operation UNKNOWN",
assertThrows(InvalidRequestException.class, () ->
AclControlManager.validateNewAcl(new AclBinding(
new ResourcePattern(TOPIC, "*", LITERAL),
- new AccessControlEntry("User:*", "*",
AclOperation.UNKNOWN, ALLOW)))).
+ new AccessControlEntry("User:*", "*",
AclOperation.UNKNOWN, ALLOW)), false)).
getMessage());
assertEquals("Invalid permissionType UNKNOWN",
assertThrows(InvalidRequestException.class, () ->
AclControlManager.validateNewAcl(new AclBinding(
new ResourcePattern(TOPIC, "*", LITERAL),
- new AccessControlEntry("User:*", "*", ALTER,
AclPermissionType.UNKNOWN)))).
+ new AccessControlEntry("User:*", "*", ALTER,
AclPermissionType.UNKNOWN)), false)).
getMessage());
}
@@ -125,7 +127,7 @@ public class AclControlManagerTest {
assertThrows(InvalidRequestException.class, () ->
AclControlManager.validateNewAcl(new AclBinding(
new ResourcePattern(TOPIC, "*", LITERAL),
- new AccessControlEntry("invalid", "*", ALTER, ALLOW)))).
+ new AccessControlEntry("invalid", "*", ALTER, ALLOW)),
false)).
getMessage());
}
@@ -139,7 +141,7 @@ public class AclControlManagerTest {
assertThrows(InvalidRequestException.class, () ->
AclControlManager.validateNewAcl(new AclBinding(
new ResourcePattern(TOPIC, "*", LITERAL),
- new AccessControlEntry("", "*", ALTER, ALLOW)))).
+ new AccessControlEntry("", "*", ALTER, ALLOW)), false)).
getMessage());
}
@@ -291,7 +293,7 @@ public class AclControlManagerTest {
new ResourcePattern(TOPIC, "*", PatternType.UNKNOWN),
new AccessControlEntry("User:*", "*", ALTER, ALLOW)));
- ControllerResult<List<AclCreateResult>> createResult =
manager.createAcls(toCreate);
+ ControllerResult<List<AclCreateResult>> createResult =
manager.createAcls(toCreate, MetadataVersion.IBP_4_0_IV0);
List<AclCreateResult> expectedResults = new ArrayList<>();
for (int i = 0; i < 3; i++) {
@@ -346,12 +348,12 @@ public class AclControlManagerTest {
AclBinding aclBinding = new AclBinding(new ResourcePattern(TOPIC,
"topic-1", LITERAL),
new AccessControlEntry("User:user", "10.0.0.1",
AclOperation.ALL, ALLOW));
- ControllerResult<List<AclCreateResult>> createResult =
manager.createAcls(List.of(aclBinding, aclBinding));
+ ControllerResult<List<AclCreateResult>> createResult =
manager.createAcls(List.of(aclBinding, aclBinding),
MetadataVersion.IBP_4_0_IV0);
RecordTestUtils.replayAll(manager, createResult.records());
assertEquals(1, createResult.records().size());
assertEquals(1, manager.idToAcl().size());
- createResult = manager.createAcls(List.of(aclBinding));
+ createResult = manager.createAcls(List.of(aclBinding),
MetadataVersion.IBP_4_0_IV0);
assertEquals(0, createResult.records().size());
assertEquals(1, manager.idToAcl().size());
}
@@ -363,7 +365,7 @@ public class AclControlManagerTest {
AclBinding aclBinding = new AclBinding(new ResourcePattern(TOPIC,
"topic-1", LITERAL),
new AccessControlEntry("User:user", "10.0.0.1",
AclOperation.ALL, ALLOW));
- ControllerResult<List<AclCreateResult>> createResult =
manager.createAcls(List.of(aclBinding));
+ ControllerResult<List<AclCreateResult>> createResult =
manager.createAcls(List.of(aclBinding), MetadataVersion.IBP_4_0_IV0);
RecordTestUtils.replayAll(manager, createResult.records());
Uuid id = ((AccessControlEntryRecord)
createResult.records().get(0).message()).id();
assertEquals(1, createResult.records().size());
@@ -413,13 +415,13 @@ public class AclControlManagerTest {
secondCreate.add(acl.toBinding());
}
}
- ControllerResult<List<AclCreateResult>> firstCreateResult =
manager.createAcls(firstCreate);
+ ControllerResult<List<AclCreateResult>> firstCreateResult =
manager.createAcls(firstCreate, MetadataVersion.IBP_4_0_IV0);
assertEquals((MAX_RECORDS_PER_USER_OP / 2) + 1,
firstCreateResult.response().size());
for (AclCreateResult result : firstCreateResult.response()) {
assertTrue(result.exception().isEmpty());
}
- ControllerResult<List<AclCreateResult>> secondCreateResult =
manager.createAcls(secondCreate);
+ ControllerResult<List<AclCreateResult>> secondCreateResult =
manager.createAcls(secondCreate, MetadataVersion.IBP_4_0_IV0);
assertEquals((MAX_RECORDS_PER_USER_OP / 2) + 1,
secondCreateResult.response().size());
for (AclCreateResult result : secondCreateResult.response()) {
assertTrue(result.exception().isEmpty());
@@ -440,4 +442,97 @@ public class AclControlManagerTest {
assertEquals(BoundedListTooLongException.class,
exception.getCause().getClass());
assertEquals("Cannot remove more than " + MAX_RECORDS_PER_USER_OP + "
acls in a single delete operation.", exception.getCause().getMessage());
}
+
+ @Test
+ public void testValidateHostPatternValid() {
+ // Wildcard
+ AclControlManager.validateHostPattern("*", false);
+ AclControlManager.validateHostPattern("*", true);
+
+ // Plain IPv4 addresses
+ AclControlManager.validateHostPattern("192.168.1.1", false);
+ AclControlManager.validateHostPattern("127.0.0.1", true);
+
+ // Plain IPv6 addresses
+ AclControlManager.validateHostPattern("2001:db8::1", false);
+ AclControlManager.validateHostPattern("::1", true);
+
+ // Hostnames (no slash, so validateHostPattern accepts them as literal
hosts)
+ AclControlManager.validateHostPattern("example.com", false);
+ AclControlManager.validateHostPattern("example.com", true);
+ }
+
+ @Test
+ public void testValidateHostPatternInvalid() {
+ // Null or empty
+ assertThrows(InvalidRequestException.class, () ->
+ AclControlManager.validateHostPattern(null, true));
+ assertThrows(InvalidRequestException.class, () ->
+ AclControlManager.validateHostPattern("", true));
+
+ // Invalid CIDR wraps CidrUtils.validate() IllegalArgumentException
into InvalidRequestException
+ InvalidRequestException e =
assertThrows(InvalidRequestException.class, () ->
+ AclControlManager.validateHostPattern("192.168.0.0/33", true));
+ assertTrue(e.getMessage().contains("Invalid CIDR notation"));
+
+ // Hostname with path is not a valid CIDR
+ e = assertThrows(InvalidRequestException.class, () ->
+ AclControlManager.validateHostPattern("example.com/test", true));
+ assertTrue(e.getMessage().contains("Invalid CIDR notation"));
+ }
+
+ @Test
+ public void testValidateHostPatternCidrNotSupported() {
+ // CIDR patterns should be rejected when cidrSupported is false
+ UnsupportedVersionException e =
assertThrows(UnsupportedVersionException.class, () ->
+ AclControlManager.validateHostPattern("192.168.0.0/24", false));
+ assertTrue(e.getMessage().contains("CIDR-based ACL host patterns
require metadata version"));
+
+ e = assertThrows(UnsupportedVersionException.class, () ->
+ AclControlManager.validateHostPattern("2001:db8::/32", false));
+ assertTrue(e.getMessage().contains("CIDR-based ACL host patterns
require metadata version"));
+ }
+
+ @Test
+ public void testCreateAclWithCidrHosts() {
+ AclControlManager manager = new AclControlManager.Builder().build();
+
+ // Valid CIDR with supported version
+ assertAclCreateSucceeds(manager, "192.168.0.0/24",
MetadataVersion.IBP_4_4_IV1);
+ assertAclCreateSucceeds(manager, "2001:db8::/32",
MetadataVersion.IBP_4_4_IV1);
+
+ // Regular hosts with older version
+ assertAclCreateSucceeds(manager, "192.168.0.1",
MetadataVersion.IBP_4_0_IV0);
+ assertAclCreateSucceeds(manager, "*", MetadataVersion.IBP_4_0_IV0);
+
+ // Invalid CIDR notation
+ assertAclCreateFails(manager, "192.168.0.0/33",
MetadataVersion.IBP_4_4_IV1,
+ InvalidRequestException.class, "Invalid CIDR notation");
+
+ // Valid CIDR with unsupported version
+ assertAclCreateFails(manager, "192.168.0.0/24",
MetadataVersion.IBP_4_0_IV0,
+ UnsupportedVersionException.class, "CIDR-based ACL host patterns
require metadata version");
+ }
+
+ private static void assertAclCreateSucceeds(AclControlManager manager,
String host, MetadataVersion version) {
+ AclBinding acl = new AclBinding(
+ new ResourcePattern(TOPIC, "topic-" + host, LITERAL),
+ new AccessControlEntry("User:test", host, ALTER, ALLOW));
+ ControllerResult<List<AclCreateResult>> result =
manager.createAcls(List.of(acl), version);
+ assertEquals(1, result.response().size());
+ assertFalse(result.response().get(0).exception().isPresent(),
+ "Expected success for host=" + host + " version=" + version);
+ }
+
+ private static void assertAclCreateFails(AclControlManager manager, String
host, MetadataVersion version,
+ Class<? extends Exception>
exClass, String messageContains) {
+ AclBinding acl = new AclBinding(
+ new ResourcePattern(TOPIC, "topic-" + host, LITERAL),
+ new AccessControlEntry("User:test", host, ALTER, ALLOW));
+ ControllerResult<List<AclCreateResult>> result =
manager.createAcls(List.of(acl), version);
+ assertEquals(1, result.response().size());
+ assertTrue(result.response().get(0).exception().isPresent());
+
assertTrue(exClass.isInstance(result.response().get(0).exception().get()));
+
assertTrue(result.response().get(0).exception().get().getMessage().contains(messageContains));
+ }
}
diff --git
a/metadata/src/test/java/org/apache/kafka/metadata/authorizer/CidrUtilsTest.java
b/metadata/src/test/java/org/apache/kafka/metadata/authorizer/CidrUtilsTest.java
new file mode 100644
index 00000000000..f648944f0ba
--- /dev/null
+++
b/metadata/src/test/java/org/apache/kafka/metadata/authorizer/CidrUtilsTest.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.metadata.authorizer;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.net.InetAddress;
+
+import static org.apache.kafka.metadata.authorizer.CidrUtils.isInRange;
+import static org.apache.kafka.metadata.authorizer.CidrUtils.validate;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+@Timeout(value = 40)
+public class CidrUtilsTest {
+
+ @Test
+ public void testValidateValidIpv4Cidr() {
+ assertDoesNotThrow(() -> validate("192.168.0.0/24"));
+ assertDoesNotThrow(() -> validate("10.0.0.0/8"));
+ assertDoesNotThrow(() -> validate("172.16.0.0/16"));
+ assertDoesNotThrow(() -> validate("192.168.1.1/32"));
+ assertDoesNotThrow(() -> validate("0.0.0.0/0"));
+ }
+
+ @Test
+ public void testValidateValidIpv6Cidr() {
+ assertDoesNotThrow(() -> validate("2001:db8::/32"));
+ assertDoesNotThrow(() -> validate("2001:db8:abcd::/48"));
+ assertDoesNotThrow(() -> validate("::1/128"));
+ assertDoesNotThrow(() -> validate("::/0"));
+ }
+
+ @Test
+ public void testValidateInvalidIpv4Cidr() {
+ assertThrows(IllegalArgumentException.class, () ->
validate("192.168.0.0/33"));
+ assertThrows(IllegalArgumentException.class, () ->
validate("192.168.0.0/-1"));
+ assertThrows(IllegalArgumentException.class, () ->
validate("192.168.0.256/24"));
+ assertThrows(IllegalArgumentException.class, () ->
validate("192.168.0.0/abc"));
+ assertThrows(IllegalArgumentException.class, () ->
validate("192.168.0.0/"));
+ }
+
+ @Test
+ public void testValidateInvalidIpv6Cidr() {
+ assertThrows(IllegalArgumentException.class, () ->
validate("2001:db8::/129"));
+ }
+
+ @Test
+ public void testValidateIpv4MappedIpv6Cidr() {
+ // ::ffff:x.x.x.x/N contains ':', so isIpv6() returns true and
SubnetUtils6 handles it.
+ assertThrows(IllegalArgumentException.class, () ->
validate("::ffff:192.168.1.0/24"));
+ }
+
+ @Test
+ public void testValidateHostnameWithPath() {
+ assertThrows(IllegalArgumentException.class, () ->
validate("example.com/test"));
+ }
+
+ @Test
+ public void testIsInRangeNullOrNonCidr() {
+ assertFalse(isInRange("192.168.1.1", null));
+ assertFalse(isInRange("192.168.1.1", "192.168.1.1"));
+ }
+
+ @Test
+ public void testIpv4MappedAddress() throws Exception {
+ // JVM normalizes ::ffff:x.x.x.x to plain IPv4 Inet4Address,
+ // so CidrUtils dispatches to SubnetUtils (IPv4 path), not
SubnetUtils6.
+ String normalizedHost =
InetAddress.getByName("::ffff:192.168.1.5").getHostAddress();
+ assertEquals("192.168.1.5", normalizedHost);
+ assertTrue(isInRange(normalizedHost, "192.168.1.0/24"));
+ assertFalse(isInRange(normalizedHost, "10.0.0.0/8"));
+ }
+
+ @Test
+ public void testIpv4MappedAddressWithSubnet() {
+ // ::ffff:x.x.x.x/N contains ':', so isIpv6() returns true and
SubnetUtils6 handles it.
+ // Plain IPv4 hosts don't match this IPv6 CIDR range.
+ assertFalse(isInRange("192.168.1.5", "::ffff:192.168.1.0/24"));
+ assertFalse(isInRange("::1", "::ffff:192.168.1.0/24"));
+ assertFalse(isInRange("2001:db8::1", "::ffff:192.168.1.0/24"));
+
+ // ::x.x.x.x/N (deprecated IPv4-compatible form): JVM keeps it as
Inet6Address,
+ // so CidrUtils dispatches to SubnetUtils6. Plain IPv4 clients won't
match.
+ assertFalse(isInRange("192.168.1.5", "::192.168.1.0/24"));
+ // IPv6 clients within the /24 range do match.
+ assertTrue(isInRange("::1", "::192.168.1.0/24"));
+ assertTrue(isInRange("0:0:0:0:0:0:c0a8:105", "::192.168.1.0/24"));
+ // IPv6 clients outside the /24 range do not match.
+ assertFalse(isInRange("2001:db8::1", "::192.168.1.0/24"));
+ }
+}
diff --git
a/metadata/src/test/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerTest.java
b/metadata/src/test/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerTest.java
index 3430259ebc5..35e49bb85f5 100644
---
a/metadata/src/test/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerTest.java
+++
b/metadata/src/test/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerTest.java
@@ -693,4 +693,186 @@ public class StandardAuthorizerTest {
// StandardAuthorizer has 4 metrics
assertEquals(5, metrics.metrics().size());
}
+
+ @Test
+ public void testAclWithCidrHost() throws Exception {
+ StandardAuthorizer authorizer =
createAndInitializeStandardAuthorizer();
+
+ StandardAcl cidrAcl = new StandardAcl(
+ TOPIC, "test-topic", LITERAL, "User:bob",
+ "192.168.1.0/24", READ, ALLOW);
+ StandardAclWithId aclWithId = withId(cidrAcl);
+ authorizer.addAcl(aclWithId.id(), aclWithId.acl());
+
+ // Boundary IPs inside the /24 range
+ for (String ip : List.of("192.168.1.0", "192.168.1.1",
"192.168.1.128", "192.168.1.255")) {
+ assertEquals(List.of(ALLOWED),
+ authorizer.authorize(
+ new MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "bob"))
+ .setClientAddress(InetAddress.getByName(ip))
+ .build(),
+ List.of(newAction(READ, TOPIC, "test-topic"))),
+ "IP " + ip + " should be allowed within 192.168.1.0/24");
+ }
+
+ // IPs just outside the range
+ for (String ip : List.of("192.168.0.255", "192.168.2.0")) {
+ assertEquals(List.of(DENIED),
+ authorizer.authorize(
+ new MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "bob"))
+ .setClientAddress(InetAddress.getByName(ip))
+ .build(),
+ List.of(newAction(READ, TOPIC, "test-topic"))),
+ "IP " + ip + " should be denied outside 192.168.1.0/24");
+ }
+ }
+
+ @Test
+ public void testAclWithIpv6CidrHost() throws Exception {
+ StandardAuthorizer authorizer =
createAndInitializeStandardAuthorizer();
+
+ StandardAcl cidrAcl = new StandardAcl(
+ TOPIC, "test-topic", LITERAL, "User:bob",
+ "2001:db8::/120", READ, ALLOW);
+ StandardAclWithId aclWithId = withId(cidrAcl);
+ authorizer.addAcl(aclWithId.id(), aclWithId.acl());
+
+ // Boundary IPs inside the /120 range
+ for (String ip : List.of("2001:db8::0", "2001:db8::1", "2001:db8::80",
"2001:db8::ff")) {
+ assertEquals(List.of(ALLOWED),
+ authorizer.authorize(
+ new MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "bob"))
+ .setClientAddress(InetAddress.getByName(ip))
+ .build(),
+ List.of(newAction(READ, TOPIC, "test-topic"))),
+ "IP " + ip + " should be allowed within 2001:db8::/120");
+ }
+
+ // IPs just outside the range
+ for (String ip : List.of("2001:db8::100", "2001:db7::1")) {
+ assertEquals(List.of(DENIED),
+ authorizer.authorize(
+ new MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "bob"))
+ .setClientAddress(InetAddress.getByName(ip))
+ .build(),
+ List.of(newAction(READ, TOPIC, "test-topic"))),
+ "IP " + ip + " should be denied outside 2001:db8::/120");
+ }
+ }
+
+ @Test
+ public void testOverlappingCidrDenyNarrowAllowWide() throws Exception {
+ StandardAuthorizer authorizer =
createAndInitializeStandardAuthorizer();
+
+ StandardAclWithId allowAcl = withId(new StandardAcl(
+ TOPIC, "foo", LITERAL, "User:alice", "10.0.0.0/8", READ, ALLOW));
+ StandardAclWithId denyAcl = withId(new StandardAcl(
+ TOPIC, "foo", LITERAL, "User:alice", "10.0.1.0/24", READ, DENY));
+ authorizer.addAcl(allowAcl.id(), allowAcl.acl());
+ authorizer.addAcl(denyAcl.id(), denyAcl.acl());
+
+ AuthorizableRequestContext aliceInDenyRange = new
MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "alice"))
+ .setClientAddress(InetAddress.getByName("10.0.1.5"))
+ .build();
+ assertEquals(List.of(DENIED),
+ authorizer.authorize(aliceInDenyRange, List.of(newAction(READ,
TOPIC, "foo"))),
+ "Client in overlapping DENY /24 inside ALLOW /8 must be denied");
+
+ AuthorizableRequestContext aliceOutsideDenyRange = new
MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "alice"))
+ .setClientAddress(InetAddress.getByName("10.0.2.1"))
+ .build();
+ assertEquals(List.of(ALLOWED),
+ authorizer.authorize(aliceOutsideDenyRange,
List.of(newAction(READ, TOPIC, "foo"))),
+ "Client in ALLOW /8 but outside DENY /24 must be allowed");
+ }
+
+ @Test
+ public void testOverlappingCidrAllowNarrowDenyWide() throws Exception {
+ StandardAuthorizer authorizer =
createAndInitializeStandardAuthorizer();
+
+ StandardAclWithId denyAcl = withId(new StandardAcl(
+ TOPIC, "foo", LITERAL, "User:alice", "10.0.0.0/8", READ, DENY));
+ StandardAclWithId allowAcl = withId(new StandardAcl(
+ TOPIC, "foo", LITERAL, "User:alice", "10.0.1.0/24", READ, ALLOW));
+ authorizer.addAcl(denyAcl.id(), denyAcl.acl());
+ authorizer.addAcl(allowAcl.id(), allowAcl.acl());
+
+ AuthorizableRequestContext aliceInBothRanges = new
MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "alice"))
+ .setClientAddress(InetAddress.getByName("10.0.1.5"))
+ .build();
+ assertEquals(List.of(DENIED),
+ authorizer.authorize(aliceInBothRanges, List.of(newAction(READ,
TOPIC, "foo"))),
+ "Narrow ALLOW /24 cannot override broad DENY /8 — DENY always
wins");
+
+ AuthorizableRequestContext aliceOnlyInDenyRange = new
MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "alice"))
+ .setClientAddress(InetAddress.getByName("10.0.2.1"))
+ .build();
+ assertEquals(List.of(DENIED),
+ authorizer.authorize(aliceOnlyInDenyRange, List.of(newAction(READ,
TOPIC, "foo"))),
+ "Client in DENY /8 range must be denied");
+ }
+
+ @Test
+ public void testOverlappingCidrWithWildcardAllow() throws Exception {
+ StandardAuthorizer authorizer =
createAndInitializeStandardAuthorizer();
+
+ StandardAclWithId allowAcl = withId(new StandardAcl(
+ TOPIC, "foo", LITERAL, "User:alice", "*", READ, ALLOW));
+ StandardAclWithId denyAcl = withId(new StandardAcl(
+ TOPIC, "foo", LITERAL, "User:alice", "10.0.1.0/24", READ, DENY));
+ authorizer.addAcl(allowAcl.id(), allowAcl.acl());
+ authorizer.addAcl(denyAcl.id(), denyAcl.acl());
+
+ AuthorizableRequestContext aliceInDenyRange = new
MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "alice"))
+ .setClientAddress(InetAddress.getByName("10.0.1.100"))
+ .build();
+ assertEquals(List.of(DENIED),
+ authorizer.authorize(aliceInDenyRange, List.of(newAction(READ,
TOPIC, "foo"))),
+ "DENY CIDR must override wildcard ALLOW");
+
+ AuthorizableRequestContext aliceOutsideDenyRange = new
MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "alice"))
+ .setClientAddress(InetAddress.getByName("10.0.2.100"))
+ .build();
+ assertEquals(List.of(ALLOWED),
+ authorizer.authorize(aliceOutsideDenyRange,
List.of(newAction(READ, TOPIC, "foo"))),
+ "Client outside DENY CIDR with wildcard ALLOW must be allowed");
+ }
+
+ @Test
+ public void testOverlappingCidrSamePermissionBothAllow() throws Exception {
+ StandardAuthorizer authorizer =
createAndInitializeStandardAuthorizer();
+
+ StandardAclWithId broadAllow = withId(new StandardAcl(
+ TOPIC, "foo", LITERAL, "User:alice", "10.0.0.0/8", READ, ALLOW));
+ StandardAclWithId narrowAllow = withId(new StandardAcl(
+ TOPIC, "foo", LITERAL, "User:alice", "10.0.1.0/24", READ, ALLOW));
+ authorizer.addAcl(broadAllow.id(), broadAllow.acl());
+ authorizer.addAcl(narrowAllow.id(), narrowAllow.acl());
+
+ AuthorizableRequestContext aliceInBothRanges = new
MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "alice"))
+ .setClientAddress(InetAddress.getByName("10.0.1.5"))
+ .build();
+ assertEquals(List.of(ALLOWED),
+ authorizer.authorize(aliceInBothRanges, List.of(newAction(READ,
TOPIC, "foo"))),
+ "Overlapping ALLOW CIDRs should both result in ALLOWED");
+
+ AuthorizableRequestContext aliceInBroadOnly = new
MockAuthorizableRequestContext.Builder()
+ .setPrincipal(new KafkaPrincipal(USER_TYPE, "alice"))
+ .setClientAddress(InetAddress.getByName("10.0.2.1"))
+ .build();
+ assertEquals(List.of(ALLOWED),
+ authorizer.authorize(aliceInBroadOnly, List.of(newAction(READ,
TOPIC, "foo"))),
+ "Client in only the broad ALLOW /8 should be allowed");
+ }
}
diff --git
a/metadata/src/testFixtures/java/org/apache/kafka/controller/MockAclMutator.java
b/metadata/src/testFixtures/java/org/apache/kafka/controller/MockAclMutator.java
index 342d164a232..72747d74e89 100644
---
a/metadata/src/testFixtures/java/org/apache/kafka/controller/MockAclMutator.java
+++
b/metadata/src/testFixtures/java/org/apache/kafka/controller/MockAclMutator.java
@@ -26,6 +26,7 @@ import org.apache.kafka.metadata.authorizer.StandardAcl;
import org.apache.kafka.metadata.authorizer.StandardAuthorizer;
import org.apache.kafka.server.authorizer.AclCreateResult;
import org.apache.kafka.server.authorizer.AclDeleteResult;
+import org.apache.kafka.server.common.MetadataVersion;
import java.util.HashMap;
import java.util.List;
@@ -72,7 +73,7 @@ public class MockAclMutator implements AclMutator {
List<AclBinding> aclBindings
) {
Map<Uuid, StandardAcl> prevIdToAcl = new
HashMap<>(aclControl.idToAcl());
- ControllerResult<List<AclCreateResult>> result =
aclControl.createAcls(aclBindings);
+ ControllerResult<List<AclCreateResult>> result =
aclControl.createAcls(aclBindings, MetadataVersion.latestTesting());
RecordTestUtils.replayAll(aclControl, result.records());
syncIdToAcl(prevIdToAcl, aclControl.idToAcl());
return CompletableFuture.completedFuture(result.response());
diff --git
a/server-common/src/main/java/org/apache/kafka/server/common/MetadataVersion.java
b/server-common/src/main/java/org/apache/kafka/server/common/MetadataVersion.java
index f93503ed905..86a11a429fb 100644
---
a/server-common/src/main/java/org/apache/kafka/server/common/MetadataVersion.java
+++
b/server-common/src/main/java/org/apache/kafka/server/common/MetadataVersion.java
@@ -133,8 +133,10 @@ public enum MetadataVersion {
// IBP_4_4_IV0 enables dead-letter queue support for share groups
(KIP-1191). When this version
// is finalized, so will the DLQ support.
- IBP_4_4_IV0(31, "4.4", "IV0", false);
+ IBP_4_4_IV0(31, "4.4", "IV0", false),
+ // Add support for CIDR-based ACL host patterns (KIP-1276).
+ IBP_4_4_IV1(32, "4.4", "IV1", true);
// NOTES when adding a new version:
// Update the default version in @ClusterTest annotation to point to the
latest version
@@ -213,6 +215,10 @@ public enum MetadataVersion {
return this.isAtLeast(IBP_4_0_IV1);
}
+ public boolean isCidrAclSupported() {
+ return this.isAtLeast(IBP_4_4_IV1);
+ }
+
public boolean isMigrationSupported() {
return this.isAtLeast(MetadataVersion.IBP_3_4_IV0);
}
diff --git
a/test-common/test-common-internal-api/src/main/java/org/apache/kafka/common/test/api/ClusterTest.java
b/test-common/test-common-internal-api/src/main/java/org/apache/kafka/common/test/api/ClusterTest.java
index af11eaaeea2..b884ec5734e 100644
---
a/test-common/test-common-internal-api/src/main/java/org/apache/kafka/common/test/api/ClusterTest.java
+++
b/test-common/test-common-internal-api/src/main/java/org/apache/kafka/common/test/api/ClusterTest.java
@@ -52,7 +52,7 @@ public @interface ClusterTest {
String brokerListener() default DEFAULT_BROKER_LISTENER_NAME;
SecurityProtocol controllerSecurityProtocol() default
SecurityProtocol.PLAINTEXT;
String controllerListener() default DEFAULT_CONTROLLER_LISTENER_NAME;
- MetadataVersion metadataVersion() default MetadataVersion.IBP_4_4_IV0;
+ MetadataVersion metadataVersion() default MetadataVersion.IBP_4_4_IV1;
ClusterConfigProperty[] serverProperties() default {};
// users can add tags that they want to display in test
String[] tags() default {};