Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add acl host matching #214

Merged
merged 1 commit into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ By default, the permission type is `ALLOW`.
"O=00000000-0000-a000-1000-(500000000005|a00000000001|b00000000001|d00000000001),ST=vm$"
),
"principal_type": "Prune",
"host": "*",
"resource": "^(.*)$",
},
{
"operation": "^(Describe|DescribeConfigs|Read|Write)$",
"principal": "^CN=(?<vmname>[a-z0-9-]+),OU=(?<nodeid>n[0-9]+),O=(?<projectid>[a-f0-9-]+),ST=vm$",
"principal_type": "Prune",
"host": "*",
"resource_pattern": "^Topic:${projectid}-(.*),
"permission_type": "DENY"
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/io/aiven/kafka/auth/AivenAclAuthorizerV2.java
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ public final List<AuthorizationResult> authorize(final AuthorizableRequestContex
final String resourceToCheck =
LegacyResourceTypeNameFormatter.format(resourcePattern.resourceType())
+ ":" + resourcePattern.name();
final String host = requestContext.clientAddress().getHostAddress();
final boolean verdict = cacheReference.get().get(principal,
host,
LegacyOperationNameFormatter.format(operation),
resourceToCheck);
final var authResult = verdict ? AuthorizationResult.ALLOWED : AuthorizationResult.DENIED;
Expand Down
12 changes: 8 additions & 4 deletions src/main/java/io/aiven/kafka/auth/VerdictCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,22 @@ private VerdictCache(@Nonnull final List<AivenAcl> denyAclEntries, @Nonnull fina
this.allowAclEntries = allowAclEntries;
}

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

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

public class AivenAcl {

private static final String WILDCARD_HOST = "*";

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

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

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

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

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

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

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

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

@Override
public String toString() {
return "AivenAcl{"
+ "principalType='" + principalType
+ "', principalRe=" + principalRe
+ ", operationRe=" + operationRe
+ ", resourceRe=" + resourceRe
+ ", resourceRePattern='" + resourceRePattern
+ "', permissionType=" + getPermissionType()
+ ", hostMatcher='" + getHostMatcher()
+ "'}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public static List<AclBinding> convert(final AivenAcl aivenAcl) {
);
for (final var principal : principals) {
final var accessControlEntry = new AccessControlEntry(
principal, "*", operation, aivenAcl.getPermissionType().nativeType);
principal, aivenAcl.getHostMatcher(), operation, aivenAcl.getPermissionType().nativeType);
for (final var resourcePattern : ResourcePatternParser.parse(aivenAcl.resourceRe.pattern())) {
result.add(new AclBinding(resourcePattern, accessControlEntry));
}
Expand Down
41 changes: 39 additions & 2 deletions src/test/java/io/aiven/kafka/auth/AivenAclAuthorizerV2Test.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

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

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

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

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

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

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

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

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

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

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

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

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

Expand All @@ -65,6 +68,7 @@ public final void parseDenyAcl() {
final var allowAcl = new AivenAcl(
"User",
"^allow$",
"*",
"^Read$",
"^(.*)$",
null,
Expand All @@ -73,6 +77,7 @@ public final void parseDenyAcl() {
final var denyAcl = new AivenAcl(
"User",
"^deny$",
"*",
"^Read$",
"^(.*)$",
null,
Expand Down
Loading
Loading