Skip to content
This repository was archived by the owner on Jan 24, 2024. It is now read-only.

Commit 9ab8411

Browse files
Optimize authorization by caching authorization results (#1999)
### Motivation To follow Kafka's behavior, KoP also performs authorization for each PRODUCE or FETCH request. If the custom authorization provider is slow to authorize produce or consume permissions, the performance will be impacted. ### Modifications Introduce caches for authorization: - PRODUCE: (topic, role) -> result - FETCH: (topic, role, group) -> result; Add `SlowAuthorizationTest` to verify the producer and consumer won't be affected significantly by slow authorization. Introduce two configs to configure the cache policy so that revoke permission can work: - kopAuthorizationCacheRefreshMs: the refresh timeout - kopAuthorizationCacheMaxCountPerConnection: the max cache size
1 parent 5193592 commit 9ab8411

File tree

10 files changed

+364
-115
lines changed

10 files changed

+364
-115
lines changed

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ This section lists configurations about the authorization.
181181
| Name | Description | Range | Default |
182182
|-------------------------------------------|--------------------------------------------------------------------------------------------------------|-------------|---------|
183183
| kafkaEnableAuthorizationForceGroupIdCheck | Whether to enable authorization force group ID check. Note: It only support for OAuth2 authentication. | true, false | false |
184+
| kopAuthorizationCacheRefreshMs | If it's configured with a positive value N, each connection will cache the authorization results of PRODUCE and FETCH requests for at least N ms.<br>It could help improve the performance when authorization is enabled, but the permission revoke will also take N ms to take effect. | 1 .. 2147483647 | 30000 |
185+
| kopAuthorizationCacheMaxCountPerConnection | If it's configured with a positive value N, each connection will cache at most N entries for PRODUCE or FETCH requests.<br>If it's non-positive, the cache size will be the default value. | 1 .. 2147483647 | 100 |
184186

185187

186188
## SSL encryption

kafka-impl/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@
117117
<artifactId>test-listener</artifactId>
118118
<scope>test</scope>
119119
</dependency>
120+
121+
<dependency>
122+
<groupId>org.awaitility</groupId>
123+
<artifactId>awaitility</artifactId>
124+
<scope>test</scope>
125+
</dependency>
120126
</dependencies>
121127

122128
<build>

kafka-impl/src/main/java/io/streamnative/pulsar/handlers/kop/KafkaServiceConfiguration.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
*/
1414
package io.streamnative.pulsar.handlers.kop;
1515

16+
import com.github.benmanes.caffeine.cache.Caffeine;
1617
import com.google.common.collect.Sets;
1718
import io.streamnative.pulsar.handlers.kop.coordinator.group.OffsetConfig;
1819
import java.io.FileInputStream;
1920
import java.io.FileNotFoundException;
2021
import java.io.IOException;
2122
import java.io.InputStream;
23+
import java.time.Duration;
2224
import java.util.Collections;
2325
import java.util.HashSet;
2426
import java.util.Properties;
@@ -564,6 +566,24 @@ public class KafkaServiceConfiguration extends ServiceConfiguration {
564566
)
565567
private boolean skipMessagesWithoutIndex = false;
566568

569+
@FieldContext(
570+
category = CATEGORY_KOP,
571+
doc = "If it's configured with a positive value N, each connection will cache the authorization results "
572+
+ "of PRODUCE and FETCH requests for at least N ms.\n"
573+
+ "It could help improve the performance when authorization is enabled, but the permission revoke "
574+
+ "will also take N ms to take effect.\nDefault: 30000 (30 seconds)"
575+
)
576+
private int kopAuthorizationCacheRefreshMs = 30000;
577+
578+
@FieldContext(
579+
category = CATEGORY_KOP,
580+
doc = "If it's configured with a positive value N, each connection will cache at most N "
581+
+ "entries for PRODUCE or FETCH requests.\n"
582+
+ "Default: 100\n"
583+
+ "If it's non-positive, the cache size will be the default value."
584+
)
585+
private int kopAuthorizationCacheMaxCountPerConnection = 100;
586+
567587
private String checkAdvertisedListeners(String advertisedListeners) {
568588
StringBuilder listenersReBuilder = new StringBuilder();
569589
for (String listener : advertisedListeners.split(EndPoint.END_POINT_SEPARATOR)) {
@@ -629,4 +649,14 @@ public String getListeners() {
629649
return kopAllowedNamespaces;
630650
}
631651

652+
public Caffeine<Object, Object> getAuthorizationCacheBuilder() {
653+
if (kopAuthorizationCacheRefreshMs <= 0) {
654+
return Caffeine.newBuilder().maximumSize(0);
655+
} else {
656+
int maximumSize = (kopAuthorizationCacheMaxCountPerConnection >= 0)
657+
? kopAuthorizationCacheMaxCountPerConnection : 100;
658+
return Caffeine.newBuilder().maximumSize(maximumSize)
659+
.expireAfterWrite(Duration.ofMillis(kopAuthorizationCacheRefreshMs));
660+
}
661+
}
632662
}

kafka-impl/src/main/java/io/streamnative/pulsar/handlers/kop/security/auth/SimpleAclAuthorizer.java

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
*/
1414
package io.streamnative.pulsar.handlers.kop.security.auth;
1515

16+
import com.github.benmanes.caffeine.cache.Cache;
1617
import io.streamnative.pulsar.handlers.kop.KafkaServiceConfiguration;
1718
import io.streamnative.pulsar.handlers.kop.security.KafkaPrincipal;
1819
import java.util.Objects;
1920
import java.util.concurrent.CompletableFuture;
2021
import lombok.extern.slf4j.Slf4j;
2122
import org.apache.commons.lang3.StringUtils;
23+
import org.apache.commons.lang3.tuple.Pair;
24+
import org.apache.commons.lang3.tuple.Triple;
2225
import org.apache.pulsar.broker.PulsarService;
2326
import org.apache.pulsar.broker.authorization.AuthorizationService;
2427
import org.apache.pulsar.common.naming.NamespaceName;
@@ -39,11 +42,18 @@ public class SimpleAclAuthorizer implements Authorizer {
3942
private final AuthorizationService authorizationService;
4043

4144
private final boolean forceCheckGroupId;
45+
// Cache the authorization results to avoid authorizing PRODUCE or FETCH requests each time.
46+
// key is (topic, role)
47+
private final Cache<Pair<TopicName, String>, Boolean> produceCache;
48+
// key is (topic, role, group)
49+
private final Cache<Triple<TopicName, String, String>, Boolean> fetchCache;
4250

4351
public SimpleAclAuthorizer(PulsarService pulsarService, KafkaServiceConfiguration config) {
4452
this.pulsarService = pulsarService;
4553
this.authorizationService = pulsarService.getBrokerService().getAuthorizationService();
4654
this.forceCheckGroupId = config.isKafkaEnableAuthorizationForceGroupIdCheck();
55+
this.produceCache = config.getAuthorizationCacheBuilder().build();
56+
this.fetchCache = config.getAuthorizationCacheBuilder().build();
4757
}
4858

4959
protected PulsarService getPulsarService() {
@@ -151,7 +161,16 @@ public CompletableFuture<Boolean> canGetTopicList(KafkaPrincipal principal, Reso
151161
public CompletableFuture<Boolean> canProduceAsync(KafkaPrincipal principal, Resource resource) {
152162
checkResourceType(resource, ResourceType.TOPIC);
153163
TopicName topicName = TopicName.get(resource.getName());
154-
return authorizationService.canProduceAsync(topicName, principal.getName(), principal.getAuthenticationData());
164+
final Pair<TopicName, String> key = Pair.of(topicName, principal.getName());
165+
final Boolean authorized = produceCache.getIfPresent(key);
166+
if (authorized != null) {
167+
return CompletableFuture.completedFuture(authorized);
168+
}
169+
return authorizationService.canProduceAsync(topicName, principal.getName(), principal.getAuthenticationData())
170+
.thenApply(__ -> {
171+
produceCache.put(key, __);
172+
return __;
173+
});
155174
}
156175

157176
@Override
@@ -161,8 +180,17 @@ public CompletableFuture<Boolean> canConsumeAsync(KafkaPrincipal principal, Reso
161180
if (forceCheckGroupId && StringUtils.isBlank(principal.getGroupId())) {
162181
return CompletableFuture.completedFuture(false);
163182
}
183+
final Triple<TopicName, String, String> key = Triple.of(topicName, principal.getName(), principal.getGroupId());
184+
final Boolean authorized = fetchCache.getIfPresent(key);
185+
if (authorized != null) {
186+
return CompletableFuture.completedFuture(authorized);
187+
}
164188
return authorizationService.canConsumeAsync(
165-
topicName, principal.getName(), principal.getAuthenticationData(), principal.getGroupId());
189+
topicName, principal.getName(), principal.getAuthenticationData(), principal.getGroupId())
190+
.thenApply(__ -> {
191+
fetchCache.put(key, __);
192+
return __;
193+
});
166194
}
167195

168196
@Override

kafka-impl/src/test/java/io/streamnative/pulsar/handlers/kop/KafkaServiceConfigurationTest.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
import static org.testng.Assert.assertEquals;
2020
import static org.testng.Assert.assertFalse;
2121
import static org.testng.Assert.assertNotNull;
22+
import static org.testng.Assert.assertNull;
2223
import static org.testng.Assert.assertTrue;
2324

25+
import com.github.benmanes.caffeine.cache.Cache;
2426
import com.google.common.collect.Sets;
2527
import io.streamnative.pulsar.handlers.kop.utils.ConfigurationUtils;
2628
import java.io.File;
@@ -31,17 +33,21 @@
3133
import java.io.PrintWriter;
3234
import java.net.InetAddress;
3335
import java.net.UnknownHostException;
36+
import java.time.Duration;
3437
import java.util.Arrays;
3538
import java.util.Collections;
39+
import java.util.Objects;
3640
import java.util.Properties;
3741
import java.util.concurrent.CompletableFuture;
42+
import java.util.stream.IntStream;
3843
import lombok.extern.slf4j.Slf4j;
3944
import org.apache.bookkeeper.client.api.DigestType;
4045
import org.apache.pulsar.broker.PulsarService;
4146
import org.apache.pulsar.broker.ServiceConfiguration;
4247
import org.apache.pulsar.broker.ServiceConfigurationUtils;
4348
import org.apache.pulsar.broker.resources.NamespaceResources;
4449
import org.apache.pulsar.broker.resources.PulsarResources;
50+
import org.awaitility.Awaitility;
4551
import org.testng.annotations.Test;
4652

4753
/**
@@ -283,4 +289,36 @@ public void testKopMigrationServiceConfiguration() {
283289
assertTrue(configuration.isKopMigrationEnable());
284290
assertEquals(port, configuration.getKopMigrationServicePort());
285291
}
292+
293+
@Test(timeOut = 10000)
294+
public void testKopAuthorizationCache() throws InterruptedException {
295+
KafkaServiceConfiguration configuration = new KafkaServiceConfiguration();
296+
configuration.setKopAuthorizationCacheRefreshMs(500);
297+
configuration.setKopAuthorizationCacheMaxCountPerConnection(5);
298+
Cache<Integer, Integer> cache = configuration.getAuthorizationCacheBuilder().build();
299+
for (int i = 0; i < 5; i++) {
300+
assertNull(cache.getIfPresent(1));
301+
}
302+
for (int i = 0; i < 10; i++) {
303+
cache.put(i, i + 100);
304+
}
305+
Awaitility.await().atMost(Duration.ofMillis(100)).pollInterval(Duration.ofMillis(1))
306+
.until(() -> IntStream.range(0, 10).mapToObj(cache::getIfPresent)
307+
.filter(Objects::nonNull).count() <= 5);
308+
IntStream.range(0, 10).mapToObj(cache::getIfPresent).filter(Objects::nonNull).map(i -> i - 100).forEach(key ->
309+
assertEquals(cache.getIfPresent(key), Integer.valueOf(key + 100)));
310+
311+
Thread.sleep(600); // wait until the cache expired
312+
for (int i = 0; i < 10; i++) {
313+
assertNull(cache.getIfPresent(i));
314+
}
315+
316+
configuration.setKopAuthorizationCacheRefreshMs(0);
317+
Cache<Integer, Integer> cache2 = configuration.getAuthorizationCacheBuilder().build();
318+
for (int i = 0; i < 5; i++) {
319+
cache2.put(i, i);
320+
}
321+
Awaitility.await().atMost(Duration.ofMillis(10)).pollInterval(Duration.ofMillis(1))
322+
.until(() -> IntStream.range(0, 5).mapToObj(cache2::getIfPresent).noneMatch(Objects::nonNull));
323+
}
286324
}

tests/src/test/java/io/streamnative/pulsar/handlers/kop/security/auth/KafkaAuthorizationMockTest.java

Lines changed: 8 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -13,130 +13,25 @@
1313
*/
1414
package io.streamnative.pulsar.handlers.kop.security.auth;
1515

16-
import static org.mockito.Mockito.spy;
17-
import static org.testng.Assert.assertEquals;
18-
import static org.testng.Assert.assertTrue;
19-
20-
import com.google.common.collect.Sets;
21-
import io.jsonwebtoken.SignatureAlgorithm;
22-
import io.streamnative.pulsar.handlers.kop.KopProtocolHandlerTestBase;
23-
import java.time.Duration;
24-
import java.util.Collections;
25-
import java.util.List;
26-
import java.util.Map;
27-
import java.util.Optional;
28-
import java.util.Properties;
29-
import javax.crypto.SecretKey;
30-
import org.apache.kafka.clients.consumer.ConsumerRecord;
31-
import org.apache.kafka.clients.consumer.ConsumerRecords;
32-
import org.apache.kafka.clients.producer.ProducerRecord;
33-
import org.apache.kafka.common.PartitionInfo;
34-
import org.apache.pulsar.broker.authentication.AuthenticationProviderToken;
35-
import org.apache.pulsar.broker.authentication.utils.AuthTokenUtils;
36-
import org.apache.pulsar.client.admin.PulsarAdmin;
3716
import org.apache.pulsar.client.admin.PulsarAdminException;
38-
import org.apache.pulsar.client.impl.auth.AuthenticationToken;
3917
import org.testng.annotations.AfterClass;
4018
import org.testng.annotations.BeforeClass;
4119
import org.testng.annotations.Test;
4220

43-
/**
44-
* Unit test for Authorization with `entryFormat=pulsar`.
45-
*/
46-
public class KafkaAuthorizationMockTest extends KopProtocolHandlerTestBase {
47-
48-
protected static final String TENANT = "KafkaAuthorizationTest";
49-
protected static final String NAMESPACE = "ns1";
50-
private static final SecretKey secretKey = AuthTokenUtils.createSecretKey(SignatureAlgorithm.HS256);
51-
52-
protected static final String ADMIN_USER = "pass.pass";
21+
public class KafkaAuthorizationMockTest extends KafkaAuthorizationMockTestBase {
5322

5423
@BeforeClass
55-
@Override
56-
protected void setup() throws Exception {
57-
Properties properties = new Properties();
58-
properties.setProperty("tokenSecretKey", AuthTokenUtils.encodeKeyBase64(secretKey));
59-
60-
String adminToken = AuthTokenUtils.createToken(secretKey, ADMIN_USER, Optional.empty());
61-
62-
conf.setSaslAllowedMechanisms(Sets.newHashSet("PLAIN"));
63-
conf.setKafkaMetadataTenant("internal");
64-
conf.setKafkaMetadataNamespace("__kafka");
65-
conf.setKafkaTenant(TENANT);
66-
conf.setKafkaNamespace(NAMESPACE);
67-
68-
conf.setClusterName(super.configClusterName);
69-
conf.setAuthorizationEnabled(true);
70-
conf.setAuthenticationEnabled(true);
71-
conf.setAuthorizationAllowWildcardsMatching(true);
72-
conf.setAuthorizationProvider(KafkaMockAuthorizationProvider.class.getName());
73-
conf.setAuthenticationProviders(
74-
Sets.newHashSet(AuthenticationProviderToken.class.getName()));
75-
conf.setBrokerClientAuthenticationPlugin(AuthenticationToken.class.getName());
76-
conf.setBrokerClientAuthenticationParameters("token:" + adminToken);
77-
conf.setProperties(properties);
78-
79-
super.internalSetup();
24+
public void setup() throws Exception {
25+
super.setup();
8026
}
8127

82-
@AfterClass
83-
@Override
84-
protected void cleanup() throws Exception {
85-
super.admin = spy(PulsarAdmin.builder().serviceHttpUrl(brokerUrl.toString())
86-
.authentication(this.conf.getBrokerClientAuthenticationPlugin(),
87-
this.conf.getBrokerClientAuthenticationParameters()).build());
28+
@AfterClass(alwaysRun = true)
29+
public void cleanup() throws Exception {
30+
super.cleanup();
8831
}
8932

90-
@Override
91-
protected void createAdmin() throws Exception {
92-
super.admin = spy(PulsarAdmin.builder().serviceHttpUrl(brokerUrl.toString())
93-
.authentication(this.conf.getBrokerClientAuthenticationPlugin(),
94-
this.conf.getBrokerClientAuthenticationParameters()).build());
95-
}
96-
97-
98-
@Test(timeOut = 30 * 1000)
33+
@Test(timeOut = 30000)
9934
public void testSuperUserProduceAndConsume() throws PulsarAdminException {
100-
String superUserToken = AuthTokenUtils.createToken(secretKey, "pass.pass", Optional.empty());
101-
String topic = "testSuperUserProduceAndConsumeTopic";
102-
String fullNewTopicName = "persistent://" + TENANT + "/" + NAMESPACE + "/" + topic;
103-
KProducer kProducer = new KProducer(topic, false, "localhost", getKafkaBrokerPort(),
104-
TENANT + "/" + NAMESPACE, "token:" + superUserToken);
105-
int totalMsgs = 10;
106-
String messageStrPrefix = topic + "_message_";
107-
108-
for (int i = 0; i < totalMsgs; i++) {
109-
String messageStr = messageStrPrefix + i;
110-
kProducer.getProducer().send(new ProducerRecord<>(topic, i, messageStr));
111-
}
112-
KConsumer kConsumer = new KConsumer(topic, "localhost", getKafkaBrokerPort(), false,
113-
TENANT + "/" + NAMESPACE, "token:" + superUserToken, "DemoKafkaOnPulsarConsumer");
114-
kConsumer.getConsumer().subscribe(Collections.singleton(topic));
115-
116-
int i = 0;
117-
while (i < totalMsgs) {
118-
ConsumerRecords<Integer, String> records = kConsumer.getConsumer().poll(Duration.ofSeconds(1));
119-
for (ConsumerRecord<Integer, String> record : records) {
120-
Integer key = record.key();
121-
assertEquals(messageStrPrefix + key.toString(), record.value());
122-
i++;
123-
}
124-
}
125-
assertEquals(i, totalMsgs);
126-
127-
// no more records
128-
ConsumerRecords<Integer, String> records = kConsumer.getConsumer().poll(Duration.ofMillis(200));
129-
assertTrue(records.isEmpty());
130-
131-
// ensure that we can list the topic
132-
Map<String, List<PartitionInfo>> result = kConsumer.getConsumer().listTopics(Duration.ofSeconds(1));
133-
assertEquals(result.size(), 1);
134-
assertTrue(result.containsKey(topic),
135-
"list of topics " + result.keySet() + " does not contains " + topic);
136-
137-
// Cleanup
138-
kProducer.close();
139-
kConsumer.close();
140-
admin.topics().deletePartitionedTopic(fullNewTopicName);
35+
super.testSuperUserProduceAndConsume();
14136
}
14237
}

0 commit comments

Comments
 (0)