Merge remote-tracking branch 'origin/master' into issue/3422
This commit is contained in:
commit
d9e7d29f54
73 changed files with 1745 additions and 343 deletions
|
@ -11,4 +11,8 @@ KafkaClient {
|
||||||
user_admin="admin-secret";
|
user_admin="admin-secret";
|
||||||
};
|
};
|
||||||
|
|
||||||
Client {};
|
Client {
|
||||||
|
org.apache.zookeeper.server.auth.DigestLoginModule required
|
||||||
|
username="zkuser"
|
||||||
|
password="zkuserpassword";
|
||||||
|
};
|
||||||
|
|
4
documentation/compose/jaas/zookeeper_jaas.conf
Normal file
4
documentation/compose/jaas/zookeeper_jaas.conf
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
Server {
|
||||||
|
org.apache.zookeeper.server.auth.DigestLoginModule required
|
||||||
|
user_zkuser="zkuserpassword";
|
||||||
|
};
|
59
documentation/compose/kafka-ui-acl-with-zk.yaml
Normal file
59
documentation/compose/kafka-ui-acl-with-zk.yaml
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
---
|
||||||
|
version: '2'
|
||||||
|
services:
|
||||||
|
|
||||||
|
kafka-ui:
|
||||||
|
container_name: kafka-ui
|
||||||
|
image: provectuslabs/kafka-ui:latest
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
depends_on:
|
||||||
|
- zookeeper
|
||||||
|
- kafka
|
||||||
|
environment:
|
||||||
|
KAFKA_CLUSTERS_0_NAME: local
|
||||||
|
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
|
||||||
|
KAFKA_CLUSTERS_0_PROPERTIES_SECURITY_PROTOCOL: SASL_PLAINTEXT
|
||||||
|
KAFKA_CLUSTERS_0_PROPERTIES_SASL_MECHANISM: PLAIN
|
||||||
|
KAFKA_CLUSTERS_0_PROPERTIES_SASL_JAAS_CONFIG: 'org.apache.kafka.common.security.plain.PlainLoginModule required username="admin" password="admin-secret";'
|
||||||
|
|
||||||
|
zookeeper:
|
||||||
|
image: wurstmeister/zookeeper:3.4.6
|
||||||
|
environment:
|
||||||
|
JVMFLAGS: "-Djava.security.auth.login.config=/etc/zookeeper/zookeeper_jaas.conf"
|
||||||
|
volumes:
|
||||||
|
- ./jaas/zookeeper_jaas.conf:/etc/zookeeper/zookeeper_jaas.conf
|
||||||
|
ports:
|
||||||
|
- 2181:2181
|
||||||
|
|
||||||
|
kafka:
|
||||||
|
image: confluentinc/cp-kafka:7.2.1
|
||||||
|
hostname: kafka
|
||||||
|
container_name: kafka
|
||||||
|
ports:
|
||||||
|
- "9092:9092"
|
||||||
|
- "9997:9997"
|
||||||
|
environment:
|
||||||
|
KAFKA_BROKER_ID: 1
|
||||||
|
KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181'
|
||||||
|
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,SASL_PLAINTEXT:SASL_PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
|
||||||
|
KAFKA_ADVERTISED_LISTENERS: 'SASL_PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092'
|
||||||
|
KAFKA_OPTS: "-Djava.security.auth.login.config=/etc/kafka/jaas/kafka_server.conf"
|
||||||
|
KAFKA_AUTHORIZER_CLASS_NAME: "kafka.security.authorizer.AclAuthorizer"
|
||||||
|
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||||
|
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
|
||||||
|
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
||||||
|
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
||||||
|
KAFKA_JMX_PORT: 9997
|
||||||
|
KAFKA_JMX_HOSTNAME: localhost
|
||||||
|
KAFKA_NODE_ID: 1
|
||||||
|
KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka:29093'
|
||||||
|
KAFKA_LISTENERS: 'SASL_PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,PLAINTEXT_HOST://0.0.0.0:9092'
|
||||||
|
KAFKA_INTER_BROKER_LISTENER_NAME: 'SASL_PLAINTEXT'
|
||||||
|
KAFKA_SASL_ENABLED_MECHANISMS: 'PLAIN'
|
||||||
|
KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: 'PLAIN'
|
||||||
|
KAFKA_SECURITY_PROTOCOL: 'SASL_PLAINTEXT'
|
||||||
|
KAFKA_SUPER_USERS: 'User:admin'
|
||||||
|
volumes:
|
||||||
|
- ./scripts/update_run.sh:/tmp/update_run.sh
|
||||||
|
- ./jaas:/etc/kafka/jaas
|
|
@ -12,7 +12,7 @@
|
||||||
<artifactId>kafka-ui-api</artifactId>
|
<artifactId>kafka-ui-api</artifactId>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<jacoco.version>0.8.8</jacoco.version>
|
<jacoco.version>0.8.10</jacoco.version>
|
||||||
<sonar.java.coveragePlugin>jacoco</sonar.java.coveragePlugin>
|
<sonar.java.coveragePlugin>jacoco</sonar.java.coveragePlugin>
|
||||||
<sonar.dynamicAnalysis>reuseReports</sonar.dynamicAnalysis>
|
<sonar.dynamicAnalysis>reuseReports</sonar.dynamicAnalysis>
|
||||||
<sonar.jacoco.reportPath>${project.basedir}/target/jacoco.exec</sonar.jacoco.reportPath>
|
<sonar.jacoco.reportPath>${project.basedir}/target/jacoco.exec</sonar.jacoco.reportPath>
|
||||||
|
|
|
@ -131,8 +131,9 @@ public class ClustersProperties {
|
||||||
@Data
|
@Data
|
||||||
public static class Masking {
|
public static class Masking {
|
||||||
Type type;
|
Type type;
|
||||||
List<String> fields; //if null or empty list - policy will be applied to all fields
|
List<String> fields;
|
||||||
List<String> pattern; //used when type=MASK
|
String fieldsNamePattern;
|
||||||
|
List<String> maskingCharsReplacement; //used when type=MASK
|
||||||
String replacement; //used when type=REPLACE
|
String replacement; //used when type=REPLACE
|
||||||
String topicKeysPattern;
|
String topicKeysPattern;
|
||||||
String topicValuesPattern;
|
String topicValuesPattern;
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.provectus.kafka.ui.config.auth;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties("spring.ldap")
|
||||||
|
@Data
|
||||||
|
public class LdapProperties {
|
||||||
|
|
||||||
|
private String urls;
|
||||||
|
private String base;
|
||||||
|
private String adminUser;
|
||||||
|
private String adminPassword;
|
||||||
|
private String userFilterSearchBase;
|
||||||
|
private String userFilterSearchFilter;
|
||||||
|
|
||||||
|
@Value("${oauth2.ldap.activeDirectory:false}")
|
||||||
|
private boolean isActiveDirectory;
|
||||||
|
@Value("${oauth2.ldap.aсtiveDirectory.domain:@null}")
|
||||||
|
private String activeDirectoryDomain;
|
||||||
|
|
||||||
|
@Value("${oauth2.ldap.groupRoleAttribute:cn}")
|
||||||
|
private String groupRoleAttribute;
|
||||||
|
|
||||||
|
}
|
|
@ -1,13 +1,23 @@
|
||||||
package com.provectus.kafka.ui.config.auth;
|
package com.provectus.kafka.ui.config.auth;
|
||||||
|
|
||||||
|
import static com.provectus.kafka.ui.config.auth.AbstractAuthSecurityConfig.AUTH_WHITELIST;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.service.rbac.AccessControlService;
|
||||||
|
import com.provectus.kafka.ui.service.rbac.extractor.RbacLdapAuthoritiesExtractor;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration;
|
import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.ldap.core.DirContextOperations;
|
||||||
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
|
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
|
||||||
import org.springframework.ldap.core.support.LdapContextSource;
|
import org.springframework.ldap.core.support.LdapContextSource;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
@ -16,70 +26,71 @@ import org.springframework.security.authentication.ReactiveAuthenticationManager
|
||||||
import org.springframework.security.authentication.ReactiveAuthenticationManagerAdapter;
|
import org.springframework.security.authentication.ReactiveAuthenticationManagerAdapter;
|
||||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.ldap.authentication.AbstractLdapAuthenticationProvider;
|
import org.springframework.security.ldap.authentication.AbstractLdapAuthenticationProvider;
|
||||||
import org.springframework.security.ldap.authentication.BindAuthenticator;
|
import org.springframework.security.ldap.authentication.BindAuthenticator;
|
||||||
import org.springframework.security.ldap.authentication.LdapAuthenticationProvider;
|
import org.springframework.security.ldap.authentication.LdapAuthenticationProvider;
|
||||||
import org.springframework.security.ldap.authentication.ad.ActiveDirectoryLdapAuthenticationProvider;
|
import org.springframework.security.ldap.authentication.ad.ActiveDirectoryLdapAuthenticationProvider;
|
||||||
import org.springframework.security.ldap.search.FilterBasedLdapUserSearch;
|
import org.springframework.security.ldap.search.FilterBasedLdapUserSearch;
|
||||||
import org.springframework.security.ldap.search.LdapUserSearch;
|
import org.springframework.security.ldap.search.LdapUserSearch;
|
||||||
|
import org.springframework.security.ldap.userdetails.LdapUserDetailsMapper;
|
||||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebFluxSecurity
|
@EnableWebFluxSecurity
|
||||||
@ConditionalOnProperty(value = "auth.type", havingValue = "LDAP")
|
@ConditionalOnProperty(value = "auth.type", havingValue = "LDAP")
|
||||||
@Import(LdapAutoConfiguration.class)
|
@Import(LdapAutoConfiguration.class)
|
||||||
|
@EnableConfigurationProperties(LdapProperties.class)
|
||||||
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class LdapSecurityConfig extends AbstractAuthSecurityConfig {
|
public class LdapSecurityConfig {
|
||||||
|
|
||||||
@Value("${spring.ldap.urls}")
|
private final LdapProperties props;
|
||||||
private String ldapUrls;
|
|
||||||
@Value("${spring.ldap.dn.pattern:#{null}}")
|
|
||||||
private String ldapUserDnPattern;
|
|
||||||
@Value("${spring.ldap.adminUser:#{null}}")
|
|
||||||
private String adminUser;
|
|
||||||
@Value("${spring.ldap.adminPassword:#{null}}")
|
|
||||||
private String adminPassword;
|
|
||||||
@Value("${spring.ldap.userFilter.searchBase:#{null}}")
|
|
||||||
private String userFilterSearchBase;
|
|
||||||
@Value("${spring.ldap.userFilter.searchFilter:#{null}}")
|
|
||||||
private String userFilterSearchFilter;
|
|
||||||
|
|
||||||
@Value("${oauth2.ldap.activeDirectory:false}")
|
|
||||||
private boolean isActiveDirectory;
|
|
||||||
@Value("${oauth2.ldap.aсtiveDirectory.domain:#{null}}")
|
|
||||||
private String activeDirectoryDomain;
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public ReactiveAuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource) {
|
public ReactiveAuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource,
|
||||||
|
ApplicationContext context,
|
||||||
|
@Nullable AccessControlService acs) {
|
||||||
|
var rbacEnabled = acs != null && acs.isRbacEnabled();
|
||||||
BindAuthenticator ba = new BindAuthenticator(contextSource);
|
BindAuthenticator ba = new BindAuthenticator(contextSource);
|
||||||
if (ldapUserDnPattern != null) {
|
if (props.getBase() != null) {
|
||||||
ba.setUserDnPatterns(new String[] {ldapUserDnPattern});
|
ba.setUserDnPatterns(new String[] {props.getBase()});
|
||||||
}
|
}
|
||||||
if (userFilterSearchFilter != null) {
|
if (props.getUserFilterSearchFilter() != null) {
|
||||||
LdapUserSearch userSearch =
|
LdapUserSearch userSearch =
|
||||||
new FilterBasedLdapUserSearch(userFilterSearchBase, userFilterSearchFilter, contextSource);
|
new FilterBasedLdapUserSearch(props.getUserFilterSearchBase(), props.getUserFilterSearchFilter(),
|
||||||
|
contextSource);
|
||||||
ba.setUserSearch(userSearch);
|
ba.setUserSearch(userSearch);
|
||||||
}
|
}
|
||||||
|
|
||||||
AbstractLdapAuthenticationProvider authenticationProvider;
|
AbstractLdapAuthenticationProvider authenticationProvider;
|
||||||
if (!isActiveDirectory) {
|
if (!props.isActiveDirectory()) {
|
||||||
authenticationProvider = new LdapAuthenticationProvider(ba);
|
authenticationProvider = rbacEnabled
|
||||||
|
? new LdapAuthenticationProvider(ba, new RbacLdapAuthoritiesExtractor(context))
|
||||||
|
: new LdapAuthenticationProvider(ba);
|
||||||
} else {
|
} else {
|
||||||
authenticationProvider = new ActiveDirectoryLdapAuthenticationProvider(activeDirectoryDomain, ldapUrls);
|
authenticationProvider = new ActiveDirectoryLdapAuthenticationProvider(props.getActiveDirectoryDomain(),
|
||||||
|
props.getUrls()); // TODO Issue #3741
|
||||||
authenticationProvider.setUseAuthenticationRequestCredentials(true);
|
authenticationProvider.setUseAuthenticationRequestCredentials(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rbacEnabled) {
|
||||||
|
authenticationProvider.setUserDetailsContextMapper(new UserDetailsMapper());
|
||||||
|
}
|
||||||
|
|
||||||
AuthenticationManager am = new ProviderManager(List.of(authenticationProvider));
|
AuthenticationManager am = new ProviderManager(List.of(authenticationProvider));
|
||||||
|
|
||||||
return new ReactiveAuthenticationManagerAdapter(am);
|
return new ReactiveAuthenticationManagerAdapter(am);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
@Primary
|
||||||
public BaseLdapPathContextSource contextSource() {
|
public BaseLdapPathContextSource contextSource() {
|
||||||
LdapContextSource ctx = new LdapContextSource();
|
LdapContextSource ctx = new LdapContextSource();
|
||||||
ctx.setUrl(ldapUrls);
|
ctx.setUrl(props.getUrls());
|
||||||
ctx.setUserDn(adminUser);
|
ctx.setUserDn(props.getAdminUser());
|
||||||
ctx.setPassword(adminPassword);
|
ctx.setPassword(props.getAdminPassword());
|
||||||
ctx.afterPropertiesSet();
|
ctx.afterPropertiesSet();
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
@ -87,20 +98,35 @@ public class LdapSecurityConfig extends AbstractAuthSecurityConfig {
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityWebFilterChain configureLdap(ServerHttpSecurity http) {
|
public SecurityWebFilterChain configureLdap(ServerHttpSecurity http) {
|
||||||
log.info("Configuring LDAP authentication.");
|
log.info("Configuring LDAP authentication.");
|
||||||
if (isActiveDirectory) {
|
if (props.isActiveDirectory()) {
|
||||||
log.info("Active Directory support for LDAP has been enabled.");
|
log.info("Active Directory support for LDAP has been enabled.");
|
||||||
}
|
}
|
||||||
|
|
||||||
http
|
return http
|
||||||
.authorizeExchange()
|
.authorizeExchange()
|
||||||
.pathMatchers(AUTH_WHITELIST)
|
.pathMatchers(AUTH_WHITELIST)
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.anyExchange()
|
.anyExchange()
|
||||||
.authenticated()
|
.authenticated()
|
||||||
.and()
|
|
||||||
.httpBasic();
|
|
||||||
|
|
||||||
return http.csrf().disable().build();
|
.and()
|
||||||
|
.formLogin()
|
||||||
|
|
||||||
|
.and()
|
||||||
|
.logout()
|
||||||
|
|
||||||
|
.and()
|
||||||
|
.csrf().disable()
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class UserDetailsMapper extends LdapUserDetailsMapper {
|
||||||
|
@Override
|
||||||
|
public UserDetails mapUserFromContext(DirContextOperations ctx, String username,
|
||||||
|
Collection<? extends GrantedAuthority> authorities) {
|
||||||
|
UserDetails userDetails = super.mapUserFromContext(ctx, username, authorities);
|
||||||
|
return new RbacLdapUser(userDetails);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -115,7 +115,7 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig {
|
||||||
@Nullable
|
@Nullable
|
||||||
private ProviderAuthorityExtractor getExtractor(final String providerId, AccessControlService acs) {
|
private ProviderAuthorityExtractor getExtractor(final String providerId, AccessControlService acs) {
|
||||||
final String provider = getProviderByProviderId(providerId);
|
final String provider = getProviderByProviderId(providerId);
|
||||||
Optional<ProviderAuthorityExtractor> extractor = acs.getExtractors()
|
Optional<ProviderAuthorityExtractor> extractor = acs.getOauthExtractors()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(e -> e.isApplicable(provider))
|
.filter(e -> e.isApplicable(provider))
|
||||||
.findFirst();
|
.findFirst();
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.provectus.kafka.ui.config.auth;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
|
||||||
|
public class RbacLdapUser implements UserDetails, RbacUser {
|
||||||
|
|
||||||
|
private final UserDetails userDetails;
|
||||||
|
|
||||||
|
public RbacLdapUser(UserDetails userDetails) {
|
||||||
|
this.userDetails = userDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return userDetails.getUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<String> groups() {
|
||||||
|
return userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||||
|
return userDetails.getAuthorities();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPassword() {
|
||||||
|
return userDetails.getPassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUsername() {
|
||||||
|
return userDetails.getUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAccountNonExpired() {
|
||||||
|
return userDetails.isAccountNonExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAccountNonLocked() {
|
||||||
|
return userDetails.isAccountNonLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCredentialsNonExpired() {
|
||||||
|
return userDetails.isCredentialsNonExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return userDetails.isEnabled();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.provectus.kafka.ui.config.auth.condition;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.AllNestedConditions;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
|
||||||
|
public class ActiveDirectoryCondition extends AllNestedConditions {
|
||||||
|
|
||||||
|
public ActiveDirectoryCondition() {
|
||||||
|
super(ConfigurationPhase.PARSE_CONFIGURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ConditionalOnProperty(value = "auth.type", havingValue = "LDAP")
|
||||||
|
public static class OnAuthType {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@ConditionalOnProperty(value = "${oauth2.ldap.activeDirectory}:false", havingValue = "true", matchIfMissing = false)
|
||||||
|
public static class OnActiveDirectory {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
package com.provectus.kafka.ui.controller;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.api.AclsApi;
|
||||||
|
import com.provectus.kafka.ui.mapper.ClusterMapper;
|
||||||
|
import com.provectus.kafka.ui.model.KafkaAclDTO;
|
||||||
|
import com.provectus.kafka.ui.model.KafkaAclNamePatternTypeDTO;
|
||||||
|
import com.provectus.kafka.ui.model.KafkaAclResourceTypeDTO;
|
||||||
|
import com.provectus.kafka.ui.model.rbac.AccessContext;
|
||||||
|
import com.provectus.kafka.ui.model.rbac.permission.AclAction;
|
||||||
|
import com.provectus.kafka.ui.service.acl.AclsService;
|
||||||
|
import com.provectus.kafka.ui.service.rbac.AccessControlService;
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.kafka.common.resource.PatternType;
|
||||||
|
import org.apache.kafka.common.resource.ResourcePatternFilter;
|
||||||
|
import org.apache.kafka.common.resource.ResourceType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AclsController extends AbstractController implements AclsApi {
|
||||||
|
|
||||||
|
private final AclsService aclsService;
|
||||||
|
private final AccessControlService accessControlService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<ResponseEntity<Void>> createAcl(String clusterName, Mono<KafkaAclDTO> kafkaAclDto,
|
||||||
|
ServerWebExchange exchange) {
|
||||||
|
AccessContext context = AccessContext.builder()
|
||||||
|
.cluster(clusterName)
|
||||||
|
.aclActions(AclAction.EDIT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return accessControlService.validateAccess(context)
|
||||||
|
.then(kafkaAclDto)
|
||||||
|
.map(ClusterMapper::toAclBinding)
|
||||||
|
.flatMap(binding -> aclsService.createAcl(getCluster(clusterName), binding))
|
||||||
|
.thenReturn(ResponseEntity.ok().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<ResponseEntity<Void>> deleteAcl(String clusterName, Mono<KafkaAclDTO> kafkaAclDto,
|
||||||
|
ServerWebExchange exchange) {
|
||||||
|
AccessContext context = AccessContext.builder()
|
||||||
|
.cluster(clusterName)
|
||||||
|
.aclActions(AclAction.EDIT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return accessControlService.validateAccess(context)
|
||||||
|
.then(kafkaAclDto)
|
||||||
|
.map(ClusterMapper::toAclBinding)
|
||||||
|
.flatMap(binding -> aclsService.deleteAcl(getCluster(clusterName), binding))
|
||||||
|
.thenReturn(ResponseEntity.ok().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<ResponseEntity<Flux<KafkaAclDTO>>> listAcls(String clusterName,
|
||||||
|
KafkaAclResourceTypeDTO resourceTypeDto,
|
||||||
|
String resourceName,
|
||||||
|
KafkaAclNamePatternTypeDTO namePatternTypeDto,
|
||||||
|
ServerWebExchange exchange) {
|
||||||
|
AccessContext context = AccessContext.builder()
|
||||||
|
.cluster(clusterName)
|
||||||
|
.aclActions(AclAction.VIEW)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
var resourceType = Optional.ofNullable(resourceTypeDto)
|
||||||
|
.map(ClusterMapper::mapAclResourceTypeDto)
|
||||||
|
.orElse(ResourceType.ANY);
|
||||||
|
|
||||||
|
var namePatternType = Optional.ofNullable(namePatternTypeDto)
|
||||||
|
.map(ClusterMapper::mapPatternTypeDto)
|
||||||
|
.orElse(PatternType.ANY);
|
||||||
|
|
||||||
|
var filter = new ResourcePatternFilter(resourceType, resourceName, namePatternType);
|
||||||
|
|
||||||
|
return accessControlService.validateAccess(context).then(
|
||||||
|
Mono.just(
|
||||||
|
ResponseEntity.ok(
|
||||||
|
aclsService.listAcls(getCluster(clusterName), filter)
|
||||||
|
.map(ClusterMapper::toKafkaAclDto)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<ResponseEntity<String>> getAclAsCsv(String clusterName, ServerWebExchange exchange) {
|
||||||
|
AccessContext context = AccessContext.builder()
|
||||||
|
.cluster(clusterName)
|
||||||
|
.aclActions(AclAction.VIEW)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return accessControlService.validateAccess(context).then(
|
||||||
|
aclsService.getAclAsCsvString(getCluster(clusterName))
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.flatMap(Mono::just)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<ResponseEntity<Void>> syncAclsCsv(String clusterName, Mono<String> csvMono, ServerWebExchange exchange) {
|
||||||
|
AccessContext context = AccessContext.builder()
|
||||||
|
.cluster(clusterName)
|
||||||
|
.aclActions(AclAction.EDIT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return accessControlService.validateAccess(context)
|
||||||
|
.then(csvMono)
|
||||||
|
.flatMap(csv -> aclsService.syncAclWithAclCsv(getCluster(clusterName), csv))
|
||||||
|
.thenReturn(ResponseEntity.ok().build());
|
||||||
|
}
|
||||||
|
}
|
|
@ -27,6 +27,7 @@ import org.mapstruct.Mapper;
|
||||||
import org.mapstruct.factory.Mappers;
|
import org.mapstruct.factory.Mappers;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.codec.multipart.FilePart;
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import org.springframework.http.codec.multipart.Part;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ServerWebExchange;
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
|
@ -92,16 +93,19 @@ public class ApplicationConfigController implements ApplicationConfigApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<ResponseEntity<UploadedFileInfoDTO>> uploadConfigRelatedFile(FilePart file, ServerWebExchange exchange) {
|
public Mono<ResponseEntity<UploadedFileInfoDTO>> uploadConfigRelatedFile(Flux<Part> fileFlux,
|
||||||
|
ServerWebExchange exchange) {
|
||||||
return accessControlService
|
return accessControlService
|
||||||
.validateAccess(
|
.validateAccess(
|
||||||
AccessContext.builder()
|
AccessContext.builder()
|
||||||
.applicationConfigActions(EDIT)
|
.applicationConfigActions(EDIT)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.then(dynamicConfigOperations.uploadConfigRelatedFile(file))
|
.then(fileFlux.single())
|
||||||
.map(path -> new UploadedFileInfoDTO().location(path.toString()))
|
.flatMap(file ->
|
||||||
.map(ResponseEntity::ok);
|
dynamicConfigOperations.uploadConfigRelatedFile((FilePart) file)
|
||||||
|
.map(path -> new UploadedFileInfoDTO().location(path.toString()))
|
||||||
|
.map(ResponseEntity::ok));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -20,6 +20,9 @@ import com.provectus.kafka.ui.model.InternalPartition;
|
||||||
import com.provectus.kafka.ui.model.InternalReplica;
|
import com.provectus.kafka.ui.model.InternalReplica;
|
||||||
import com.provectus.kafka.ui.model.InternalTopic;
|
import com.provectus.kafka.ui.model.InternalTopic;
|
||||||
import com.provectus.kafka.ui.model.InternalTopicConfig;
|
import com.provectus.kafka.ui.model.InternalTopicConfig;
|
||||||
|
import com.provectus.kafka.ui.model.KafkaAclDTO;
|
||||||
|
import com.provectus.kafka.ui.model.KafkaAclNamePatternTypeDTO;
|
||||||
|
import com.provectus.kafka.ui.model.KafkaAclResourceTypeDTO;
|
||||||
import com.provectus.kafka.ui.model.MetricDTO;
|
import com.provectus.kafka.ui.model.MetricDTO;
|
||||||
import com.provectus.kafka.ui.model.Metrics;
|
import com.provectus.kafka.ui.model.Metrics;
|
||||||
import com.provectus.kafka.ui.model.PartitionDTO;
|
import com.provectus.kafka.ui.model.PartitionDTO;
|
||||||
|
@ -27,12 +30,18 @@ import com.provectus.kafka.ui.model.ReplicaDTO;
|
||||||
import com.provectus.kafka.ui.model.TopicConfigDTO;
|
import com.provectus.kafka.ui.model.TopicConfigDTO;
|
||||||
import com.provectus.kafka.ui.model.TopicDTO;
|
import com.provectus.kafka.ui.model.TopicDTO;
|
||||||
import com.provectus.kafka.ui.model.TopicDetailsDTO;
|
import com.provectus.kafka.ui.model.TopicDetailsDTO;
|
||||||
import com.provectus.kafka.ui.service.masking.DataMasking;
|
|
||||||
import com.provectus.kafka.ui.service.metrics.RawMetric;
|
import com.provectus.kafka.ui.service.metrics.RawMetric;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import org.apache.kafka.clients.admin.ConfigEntry;
|
import org.apache.kafka.clients.admin.ConfigEntry;
|
||||||
|
import org.apache.kafka.common.acl.AccessControlEntry;
|
||||||
|
import org.apache.kafka.common.acl.AclBinding;
|
||||||
|
import org.apache.kafka.common.acl.AclOperation;
|
||||||
|
import org.apache.kafka.common.acl.AclPermissionType;
|
||||||
|
import org.apache.kafka.common.resource.PatternType;
|
||||||
|
import org.apache.kafka.common.resource.ResourcePattern;
|
||||||
|
import org.apache.kafka.common.resource.ResourceType;
|
||||||
import org.mapstruct.Mapper;
|
import org.mapstruct.Mapper;
|
||||||
import org.mapstruct.Mapping;
|
import org.mapstruct.Mapping;
|
||||||
|
|
||||||
|
@ -109,8 +118,74 @@ public interface ClusterMapper {
|
||||||
return brokerDiskUsage;
|
return brokerDiskUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
default DataMasking map(List<ClustersProperties.Masking> maskingProperties) {
|
static KafkaAclDTO.OperationEnum mapAclOperation(AclOperation operation) {
|
||||||
return DataMasking.create(maskingProperties);
|
return switch (operation) {
|
||||||
|
case ALL -> KafkaAclDTO.OperationEnum.ALL;
|
||||||
|
case READ -> KafkaAclDTO.OperationEnum.READ;
|
||||||
|
case WRITE -> KafkaAclDTO.OperationEnum.WRITE;
|
||||||
|
case CREATE -> KafkaAclDTO.OperationEnum.CREATE;
|
||||||
|
case DELETE -> KafkaAclDTO.OperationEnum.DELETE;
|
||||||
|
case ALTER -> KafkaAclDTO.OperationEnum.ALTER;
|
||||||
|
case DESCRIBE -> KafkaAclDTO.OperationEnum.DESCRIBE;
|
||||||
|
case CLUSTER_ACTION -> KafkaAclDTO.OperationEnum.CLUSTER_ACTION;
|
||||||
|
case DESCRIBE_CONFIGS -> KafkaAclDTO.OperationEnum.DESCRIBE_CONFIGS;
|
||||||
|
case ALTER_CONFIGS -> KafkaAclDTO.OperationEnum.ALTER_CONFIGS;
|
||||||
|
case IDEMPOTENT_WRITE -> KafkaAclDTO.OperationEnum.IDEMPOTENT_WRITE;
|
||||||
|
case CREATE_TOKENS -> KafkaAclDTO.OperationEnum.CREATE_TOKENS;
|
||||||
|
case DESCRIBE_TOKENS -> KafkaAclDTO.OperationEnum.DESCRIBE_TOKENS;
|
||||||
|
case ANY -> throw new IllegalArgumentException("ANY operation can be only part of filter");
|
||||||
|
case UNKNOWN -> KafkaAclDTO.OperationEnum.UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static KafkaAclResourceTypeDTO mapAclResourceType(ResourceType resourceType) {
|
||||||
|
return switch (resourceType) {
|
||||||
|
case CLUSTER -> KafkaAclResourceTypeDTO.CLUSTER;
|
||||||
|
case TOPIC -> KafkaAclResourceTypeDTO.TOPIC;
|
||||||
|
case GROUP -> KafkaAclResourceTypeDTO.GROUP;
|
||||||
|
case DELEGATION_TOKEN -> KafkaAclResourceTypeDTO.DELEGATION_TOKEN;
|
||||||
|
case TRANSACTIONAL_ID -> KafkaAclResourceTypeDTO.TRANSACTIONAL_ID;
|
||||||
|
case USER -> KafkaAclResourceTypeDTO.USER;
|
||||||
|
case ANY -> throw new IllegalArgumentException("ANY type can be only part of filter");
|
||||||
|
case UNKNOWN -> KafkaAclResourceTypeDTO.UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static ResourceType mapAclResourceTypeDto(KafkaAclResourceTypeDTO dto) {
|
||||||
|
return ResourceType.valueOf(dto.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
static PatternType mapPatternTypeDto(KafkaAclNamePatternTypeDTO dto) {
|
||||||
|
return PatternType.valueOf(dto.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
static AclBinding toAclBinding(KafkaAclDTO dto) {
|
||||||
|
return new AclBinding(
|
||||||
|
new ResourcePattern(
|
||||||
|
mapAclResourceTypeDto(dto.getResourceType()),
|
||||||
|
dto.getResourceName(),
|
||||||
|
mapPatternTypeDto(dto.getNamePatternType())
|
||||||
|
),
|
||||||
|
new AccessControlEntry(
|
||||||
|
dto.getPrincipal(),
|
||||||
|
dto.getHost(),
|
||||||
|
AclOperation.valueOf(dto.getOperation().name()),
|
||||||
|
AclPermissionType.valueOf(dto.getPermission().name())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static KafkaAclDTO toKafkaAclDto(AclBinding binding) {
|
||||||
|
var pattern = binding.pattern();
|
||||||
|
var filter = binding.toFilter().entryFilter();
|
||||||
|
return new KafkaAclDTO()
|
||||||
|
.resourceType(mapAclResourceType(pattern.resourceType()))
|
||||||
|
.resourceName(pattern.name())
|
||||||
|
.namePatternType(KafkaAclNamePatternTypeDTO.fromValue(pattern.patternType().name()))
|
||||||
|
.principal(filter.principal())
|
||||||
|
.host(filter.host())
|
||||||
|
.operation(mapAclOperation(filter.operation()))
|
||||||
|
.permission(KafkaAclDTO.PermissionEnum.fromValue(filter.permissionType().name()));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,5 +4,7 @@ public enum ClusterFeature {
|
||||||
KAFKA_CONNECT,
|
KAFKA_CONNECT,
|
||||||
KSQL_DB,
|
KSQL_DB,
|
||||||
SCHEMA_REGISTRY,
|
SCHEMA_REGISTRY,
|
||||||
TOPIC_DELETION
|
TOPIC_DELETION,
|
||||||
|
KAFKA_ACL_VIEW,
|
||||||
|
KAFKA_ACL_EDIT
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package com.provectus.kafka.ui.model;
|
package com.provectus.kafka.ui.model;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.MathContext;
|
import java.math.RoundingMode;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
|
@ -21,8 +21,6 @@ public class PartitionDistributionStats {
|
||||||
// avg skew will show unuseful results on low number of partitions
|
// avg skew will show unuseful results on low number of partitions
|
||||||
private static final int MIN_PARTITIONS_FOR_SKEW_CALCULATION = 50;
|
private static final int MIN_PARTITIONS_FOR_SKEW_CALCULATION = 50;
|
||||||
|
|
||||||
private static final MathContext ROUNDING_MATH_CTX = new MathContext(3);
|
|
||||||
|
|
||||||
private final Map<Node, Integer> partitionLeaders;
|
private final Map<Node, Integer> partitionLeaders;
|
||||||
private final Map<Node, Integer> partitionsCount;
|
private final Map<Node, Integer> partitionsCount;
|
||||||
private final Map<Node, Integer> inSyncPartitions;
|
private final Map<Node, Integer> inSyncPartitions;
|
||||||
|
@ -88,6 +86,7 @@ public class PartitionDistributionStats {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
value = value == null ? 0 : value;
|
value = value == null ? 0 : value;
|
||||||
return new BigDecimal((value - avgValue) / avgValue * 100.0).round(ROUNDING_MATH_CTX);
|
return new BigDecimal((value - avgValue) / avgValue * 100.0)
|
||||||
|
.setScale(1, RoundingMode.HALF_UP);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.provectus.kafka.ui.model.rbac;
|
package com.provectus.kafka.ui.model.rbac;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.model.rbac.permission.AclAction;
|
||||||
import com.provectus.kafka.ui.model.rbac.permission.ApplicationConfigAction;
|
import com.provectus.kafka.ui.model.rbac.permission.ApplicationConfigAction;
|
||||||
import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
|
import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
|
||||||
import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
|
import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
|
||||||
|
@ -37,6 +38,8 @@ public class AccessContext {
|
||||||
|
|
||||||
Collection<KsqlAction> ksqlActions;
|
Collection<KsqlAction> ksqlActions;
|
||||||
|
|
||||||
|
Collection<AclAction> aclActions;
|
||||||
|
|
||||||
public static AccessContextBuilder builder() {
|
public static AccessContextBuilder builder() {
|
||||||
return new AccessContextBuilder();
|
return new AccessContextBuilder();
|
||||||
}
|
}
|
||||||
|
@ -55,6 +58,7 @@ public class AccessContext {
|
||||||
private String schema;
|
private String schema;
|
||||||
private Collection<SchemaAction> schemaActions = Collections.emptySet();
|
private Collection<SchemaAction> schemaActions = Collections.emptySet();
|
||||||
private Collection<KsqlAction> ksqlActions = Collections.emptySet();
|
private Collection<KsqlAction> ksqlActions = Collections.emptySet();
|
||||||
|
private Collection<AclAction> aclActions = Collections.emptySet();
|
||||||
|
|
||||||
private AccessContextBuilder() {
|
private AccessContextBuilder() {
|
||||||
}
|
}
|
||||||
|
@ -131,6 +135,12 @@ public class AccessContext {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AccessContextBuilder aclActions(AclAction... actions) {
|
||||||
|
Assert.isTrue(actions.length > 0, "actions not present");
|
||||||
|
this.aclActions = List.of(actions);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public AccessContext build() {
|
public AccessContext build() {
|
||||||
return new AccessContext(
|
return new AccessContext(
|
||||||
applicationConfigActions,
|
applicationConfigActions,
|
||||||
|
@ -140,7 +150,7 @@ public class AccessContext {
|
||||||
connect, connectActions,
|
connect, connectActions,
|
||||||
connector,
|
connector,
|
||||||
schema, schemaActions,
|
schema, schemaActions,
|
||||||
ksqlActions);
|
ksqlActions, aclActions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import static com.provectus.kafka.ui.model.rbac.Resource.APPLICATIONCONFIG;
|
||||||
import static com.provectus.kafka.ui.model.rbac.Resource.CLUSTERCONFIG;
|
import static com.provectus.kafka.ui.model.rbac.Resource.CLUSTERCONFIG;
|
||||||
import static com.provectus.kafka.ui.model.rbac.Resource.KSQL;
|
import static com.provectus.kafka.ui.model.rbac.Resource.KSQL;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.model.rbac.permission.AclAction;
|
||||||
import com.provectus.kafka.ui.model.rbac.permission.ApplicationConfigAction;
|
import com.provectus.kafka.ui.model.rbac.permission.ApplicationConfigAction;
|
||||||
import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
|
import com.provectus.kafka.ui.model.rbac.permission.ClusterConfigAction;
|
||||||
import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
|
import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
|
||||||
|
@ -76,6 +77,7 @@ public class Permission {
|
||||||
case SCHEMA -> Arrays.stream(SchemaAction.values()).map(Enum::toString).toList();
|
case SCHEMA -> Arrays.stream(SchemaAction.values()).map(Enum::toString).toList();
|
||||||
case CONNECT -> Arrays.stream(ConnectAction.values()).map(Enum::toString).toList();
|
case CONNECT -> Arrays.stream(ConnectAction.values()).map(Enum::toString).toList();
|
||||||
case KSQL -> Arrays.stream(KsqlAction.values()).map(Enum::toString).toList();
|
case KSQL -> Arrays.stream(KsqlAction.values()).map(Enum::toString).toList();
|
||||||
|
case ACL -> Arrays.stream(AclAction.values()).map(Enum::toString).toList();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,8 @@ public enum Resource {
|
||||||
CONSUMER,
|
CONSUMER,
|
||||||
SCHEMA,
|
SCHEMA,
|
||||||
CONNECT,
|
CONNECT,
|
||||||
KSQL;
|
KSQL,
|
||||||
|
ACL;
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public static Resource fromString(String name) {
|
public static Resource fromString(String name) {
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.provectus.kafka.ui.model.rbac.permission;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.EnumUtils;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
public enum AclAction implements PermissibleAction {
|
||||||
|
|
||||||
|
VIEW,
|
||||||
|
EDIT;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static AclAction fromString(String name) {
|
||||||
|
return EnumUtils.getEnum(AclAction.class, name);
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,16 +2,19 @@ package com.provectus.kafka.ui.service;
|
||||||
|
|
||||||
import com.provectus.kafka.ui.model.ClusterFeature;
|
import com.provectus.kafka.ui.model.ClusterFeature;
|
||||||
import com.provectus.kafka.ui.model.KafkaCluster;
|
import com.provectus.kafka.ui.model.KafkaCluster;
|
||||||
|
import com.provectus.kafka.ui.service.ReactiveAdminClient.ClusterDescription;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.kafka.common.Node;
|
import org.apache.kafka.common.Node;
|
||||||
|
import org.apache.kafka.common.acl.AclOperation;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
@ -26,7 +29,7 @@ public class FeatureService {
|
||||||
private final AdminClientService adminClientService;
|
private final AdminClientService adminClientService;
|
||||||
|
|
||||||
public Mono<List<ClusterFeature>> getAvailableFeatures(KafkaCluster cluster,
|
public Mono<List<ClusterFeature>> getAvailableFeatures(KafkaCluster cluster,
|
||||||
ReactiveAdminClient.ClusterDescription clusterDescription) {
|
ClusterDescription clusterDescription) {
|
||||||
List<Mono<ClusterFeature>> features = new ArrayList<>();
|
List<Mono<ClusterFeature>> features = new ArrayList<>();
|
||||||
|
|
||||||
if (Optional.ofNullable(cluster.getConnectsClients())
|
if (Optional.ofNullable(cluster.getConnectsClients())
|
||||||
|
@ -44,6 +47,8 @@ public class FeatureService {
|
||||||
}
|
}
|
||||||
|
|
||||||
features.add(topicDeletionEnabled(cluster, clusterDescription.getController()));
|
features.add(topicDeletionEnabled(cluster, clusterDescription.getController()));
|
||||||
|
features.add(aclView(cluster));
|
||||||
|
features.add(aclEdit(clusterDescription));
|
||||||
|
|
||||||
return Flux.fromIterable(features).flatMap(m -> m).collectList();
|
return Flux.fromIterable(features).flatMap(m -> m).collectList();
|
||||||
}
|
}
|
||||||
|
@ -65,4 +70,20 @@ public class FeatureService {
|
||||||
? Mono.just(ClusterFeature.TOPIC_DELETION)
|
? Mono.just(ClusterFeature.TOPIC_DELETION)
|
||||||
: Mono.empty());
|
: Mono.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<ClusterFeature> aclEdit(ClusterDescription clusterDescription) {
|
||||||
|
var authorizedOps = Optional.ofNullable(clusterDescription.getAuthorizedOperations()).orElse(Set.of());
|
||||||
|
boolean canEdit = authorizedOps.contains(AclOperation.ALL) || authorizedOps.contains(AclOperation.ALTER);
|
||||||
|
return canEdit
|
||||||
|
? Mono.just(ClusterFeature.KAFKA_ACL_EDIT)
|
||||||
|
: Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ClusterFeature> aclView(KafkaCluster cluster) {
|
||||||
|
return adminClientService.get(cluster).flatMap(
|
||||||
|
ac -> ac.getClusterFeatures().contains(ReactiveAdminClient.SupportedFeature.AUTHORIZED_SECURITY_ENABLED)
|
||||||
|
? Mono.just(ClusterFeature.KAFKA_ACL_VIEW)
|
||||||
|
: Mono.empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import static java.util.stream.Collectors.toMap;
|
||||||
import static org.apache.kafka.clients.admin.ListOffsetsResult.ListOffsetsResultInfo;
|
import static org.apache.kafka.clients.admin.ListOffsetsResult.ListOffsetsResultInfo;
|
||||||
|
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import com.google.common.base.Preconditions;
|
||||||
import com.google.common.collect.ImmutableTable;
|
import com.google.common.collect.ImmutableTable;
|
||||||
import com.google.common.collect.Iterables;
|
import com.google.common.collect.Iterables;
|
||||||
import com.google.common.collect.Table;
|
import com.google.common.collect.Table;
|
||||||
|
@ -15,7 +16,6 @@ import com.provectus.kafka.ui.util.KafkaVersion;
|
||||||
import com.provectus.kafka.ui.util.annotation.KafkaClientInternalsDependant;
|
import com.provectus.kafka.ui.util.annotation.KafkaClientInternalsDependant;
|
||||||
import java.io.Closeable;
|
import java.io.Closeable;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
@ -61,16 +61,22 @@ import org.apache.kafka.common.Node;
|
||||||
import org.apache.kafka.common.TopicPartition;
|
import org.apache.kafka.common.TopicPartition;
|
||||||
import org.apache.kafka.common.TopicPartitionInfo;
|
import org.apache.kafka.common.TopicPartitionInfo;
|
||||||
import org.apache.kafka.common.TopicPartitionReplica;
|
import org.apache.kafka.common.TopicPartitionReplica;
|
||||||
|
import org.apache.kafka.common.acl.AccessControlEntryFilter;
|
||||||
|
import org.apache.kafka.common.acl.AclBinding;
|
||||||
|
import org.apache.kafka.common.acl.AclBindingFilter;
|
||||||
import org.apache.kafka.common.acl.AclOperation;
|
import org.apache.kafka.common.acl.AclOperation;
|
||||||
import org.apache.kafka.common.config.ConfigResource;
|
import org.apache.kafka.common.config.ConfigResource;
|
||||||
import org.apache.kafka.common.errors.ClusterAuthorizationException;
|
import org.apache.kafka.common.errors.ClusterAuthorizationException;
|
||||||
import org.apache.kafka.common.errors.GroupIdNotFoundException;
|
import org.apache.kafka.common.errors.GroupIdNotFoundException;
|
||||||
import org.apache.kafka.common.errors.GroupNotEmptyException;
|
import org.apache.kafka.common.errors.GroupNotEmptyException;
|
||||||
import org.apache.kafka.common.errors.InvalidRequestException;
|
import org.apache.kafka.common.errors.InvalidRequestException;
|
||||||
|
import org.apache.kafka.common.errors.SecurityDisabledException;
|
||||||
import org.apache.kafka.common.errors.TopicAuthorizationException;
|
import org.apache.kafka.common.errors.TopicAuthorizationException;
|
||||||
import org.apache.kafka.common.errors.UnknownTopicOrPartitionException;
|
import org.apache.kafka.common.errors.UnknownTopicOrPartitionException;
|
||||||
import org.apache.kafka.common.errors.UnsupportedVersionException;
|
import org.apache.kafka.common.errors.UnsupportedVersionException;
|
||||||
import org.apache.kafka.common.requests.DescribeLogDirsResponse;
|
import org.apache.kafka.common.requests.DescribeLogDirsResponse;
|
||||||
|
import org.apache.kafka.common.resource.ResourcePattern;
|
||||||
|
import org.apache.kafka.common.resource.ResourcePatternFilter;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
@ -82,26 +88,29 @@ import reactor.util.function.Tuples;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ReactiveAdminClient implements Closeable {
|
public class ReactiveAdminClient implements Closeable {
|
||||||
|
|
||||||
private enum SupportedFeature {
|
public enum SupportedFeature {
|
||||||
INCREMENTAL_ALTER_CONFIGS(2.3f),
|
INCREMENTAL_ALTER_CONFIGS(2.3f),
|
||||||
CONFIG_DOCUMENTATION_RETRIEVAL(2.6f),
|
CONFIG_DOCUMENTATION_RETRIEVAL(2.6f),
|
||||||
DESCRIBE_CLUSTER_INCLUDE_AUTHORIZED_OPERATIONS(2.3f);
|
DESCRIBE_CLUSTER_INCLUDE_AUTHORIZED_OPERATIONS(2.3f),
|
||||||
|
AUTHORIZED_SECURITY_ENABLED(ReactiveAdminClient::isAuthorizedSecurityEnabled);
|
||||||
|
|
||||||
private final float sinceVersion;
|
private final BiFunction<AdminClient, Float, Mono<Boolean>> predicate;
|
||||||
|
|
||||||
SupportedFeature(float sinceVersion) {
|
SupportedFeature(BiFunction<AdminClient, Float, Mono<Boolean>> predicate) {
|
||||||
this.sinceVersion = sinceVersion;
|
this.predicate = predicate;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Set<SupportedFeature> forVersion(float kafkaVersion) {
|
SupportedFeature(float fromVersion) {
|
||||||
return Arrays.stream(SupportedFeature.values())
|
this.predicate = (admin, ver) -> Mono.just(ver != null && ver >= fromVersion);
|
||||||
.filter(f -> kafkaVersion >= f.sinceVersion)
|
}
|
||||||
|
|
||||||
|
static Mono<Set<SupportedFeature>> forVersion(AdminClient ac, @Nullable Float kafkaVersion) {
|
||||||
|
return Flux.fromArray(SupportedFeature.values())
|
||||||
|
.flatMap(f -> f.predicate.apply(ac, kafkaVersion).map(enabled -> Tuples.of(f, enabled)))
|
||||||
|
.filter(Tuple2::getT2)
|
||||||
|
.map(Tuple2::getT1)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
static Set<SupportedFeature> defaultFeatures() {
|
|
||||||
return Set.of();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Value
|
@Value
|
||||||
|
@ -110,25 +119,31 @@ public class ReactiveAdminClient implements Closeable {
|
||||||
Node controller;
|
Node controller;
|
||||||
String clusterId;
|
String clusterId;
|
||||||
Collection<Node> nodes;
|
Collection<Node> nodes;
|
||||||
|
@Nullable // null, if ACL is disabled
|
||||||
Set<AclOperation> authorizedOperations;
|
Set<AclOperation> authorizedOperations;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Mono<ReactiveAdminClient> create(AdminClient adminClient) {
|
public static Mono<ReactiveAdminClient> create(AdminClient adminClient) {
|
||||||
return getClusterVersion(adminClient)
|
return getClusterVersion(adminClient)
|
||||||
.map(ver ->
|
.flatMap(ver ->
|
||||||
new ReactiveAdminClient(
|
getSupportedUpdateFeaturesForVersion(adminClient, ver)
|
||||||
adminClient,
|
.map(features ->
|
||||||
ver,
|
new ReactiveAdminClient(adminClient, ver, features)));
|
||||||
getSupportedUpdateFeaturesForVersion(ver)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Set<SupportedFeature> getSupportedUpdateFeaturesForVersion(String versionStr) {
|
private static Mono<Set<SupportedFeature>> getSupportedUpdateFeaturesForVersion(AdminClient ac, String versionStr) {
|
||||||
try {
|
@Nullable Float kafkaVersion = KafkaVersion.parse(versionStr).orElse(null);
|
||||||
float version = KafkaVersion.parse(versionStr);
|
return SupportedFeature.forVersion(ac, kafkaVersion);
|
||||||
return SupportedFeature.forVersion(version);
|
}
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return SupportedFeature.defaultFeatures();
|
private static Mono<Boolean> isAuthorizedSecurityEnabled(AdminClient ac, @Nullable Float kafkaVersion) {
|
||||||
}
|
return toMono(ac.describeAcls(AclBindingFilter.ANY).values())
|
||||||
|
.thenReturn(true)
|
||||||
|
.doOnError(th -> !(th instanceof SecurityDisabledException)
|
||||||
|
&& !(th instanceof InvalidRequestException)
|
||||||
|
&& !(th instanceof UnsupportedVersionException),
|
||||||
|
th -> log.warn("Error checking if security enabled", th))
|
||||||
|
.onErrorReturn(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: if KafkaFuture returns null, that Mono will be empty(!), since Reactor does not support nullable results
|
// NOTE: if KafkaFuture returns null, that Mono will be empty(!), since Reactor does not support nullable results
|
||||||
|
@ -162,6 +177,10 @@ public class ReactiveAdminClient implements Closeable {
|
||||||
private final String version;
|
private final String version;
|
||||||
private final Set<SupportedFeature> features;
|
private final Set<SupportedFeature> features;
|
||||||
|
|
||||||
|
public Set<SupportedFeature> getClusterFeatures() {
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
public Mono<Set<String>> listTopics(boolean listInternal) {
|
public Mono<Set<String>> listTopics(boolean listInternal) {
|
||||||
return toMono(client.listTopics(new ListTopicsOptions().listInternal(listInternal)).names());
|
return toMono(client.listTopics(new ListTopicsOptions().listInternal(listInternal)).names());
|
||||||
}
|
}
|
||||||
|
@ -576,6 +595,22 @@ public class ReactiveAdminClient implements Closeable {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Mono<Collection<AclBinding>> listAcls(ResourcePatternFilter filter) {
|
||||||
|
Preconditions.checkArgument(features.contains(SupportedFeature.AUTHORIZED_SECURITY_ENABLED));
|
||||||
|
return toMono(client.describeAcls(new AclBindingFilter(filter, AccessControlEntryFilter.ANY)).values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> createAcls(Collection<AclBinding> aclBindings) {
|
||||||
|
Preconditions.checkArgument(features.contains(SupportedFeature.AUTHORIZED_SECURITY_ENABLED));
|
||||||
|
return toMono(client.createAcls(aclBindings).all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteAcls(Collection<AclBinding> aclBindings) {
|
||||||
|
Preconditions.checkArgument(features.contains(SupportedFeature.AUTHORIZED_SECURITY_ENABLED));
|
||||||
|
var filters = aclBindings.stream().map(AclBinding::toFilter).collect(Collectors.toSet());
|
||||||
|
return toMono(client.deleteAcls(filters).all()).then();
|
||||||
|
}
|
||||||
|
|
||||||
public Mono<Void> updateBrokerConfigByName(Integer brokerId, String name, String value) {
|
public Mono<Void> updateBrokerConfigByName(Integer brokerId, String name, String value) {
|
||||||
ConfigResource cr = new ConfigResource(ConfigResource.Type.BROKER, String.valueOf(brokerId));
|
ConfigResource cr = new ConfigResource(ConfigResource.Type.BROKER, String.valueOf(brokerId));
|
||||||
AlterConfigOp op = new AlterConfigOp(new ConfigEntry(name, value), AlterConfigOp.OpType.SET);
|
AlterConfigOp op = new AlterConfigOp(new ConfigEntry(name, value), AlterConfigOp.OpType.SET);
|
||||||
|
|
|
@ -0,0 +1,81 @@
|
||||||
|
package com.provectus.kafka.ui.service.acl;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.exception.ValidationException;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import org.apache.kafka.common.acl.AccessControlEntry;
|
||||||
|
import org.apache.kafka.common.acl.AclBinding;
|
||||||
|
import org.apache.kafka.common.acl.AclOperation;
|
||||||
|
import org.apache.kafka.common.acl.AclPermissionType;
|
||||||
|
import org.apache.kafka.common.resource.PatternType;
|
||||||
|
import org.apache.kafka.common.resource.ResourcePattern;
|
||||||
|
import org.apache.kafka.common.resource.ResourceType;
|
||||||
|
|
||||||
|
public class AclCsv {
|
||||||
|
|
||||||
|
private static final String LINE_SEPARATOR = System.lineSeparator();
|
||||||
|
private static final String VALUES_SEPARATOR = ",";
|
||||||
|
private static final String HEADER = "Principal,ResourceType,PatternType,ResourceName,Operation,PermissionType,Host";
|
||||||
|
|
||||||
|
public static String transformToCsvString(Collection<AclBinding> acls) {
|
||||||
|
return Stream.concat(Stream.of(HEADER), acls.stream().map(AclCsv::createAclString))
|
||||||
|
.collect(Collectors.joining(System.lineSeparator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String createAclString(AclBinding binding) {
|
||||||
|
var pattern = binding.pattern();
|
||||||
|
var filter = binding.toFilter().entryFilter();
|
||||||
|
return String.format(
|
||||||
|
"%s,%s,%s,%s,%s,%s,%s",
|
||||||
|
filter.principal(),
|
||||||
|
pattern.resourceType(),
|
||||||
|
pattern.patternType(),
|
||||||
|
pattern.name(),
|
||||||
|
filter.operation(),
|
||||||
|
filter.permissionType(),
|
||||||
|
filter.host()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AclBinding parseCsvLine(String csv, int line) {
|
||||||
|
String[] values = csv.split(VALUES_SEPARATOR);
|
||||||
|
if (values.length != 7) {
|
||||||
|
throw new ValidationException("Input csv is not valid - there should be 7 columns in line " + line);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
if ((values[i] = values[i].trim()).isBlank()) {
|
||||||
|
throw new ValidationException("Input csv is not valid - blank value in colum " + i + ", line " + line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new AclBinding(
|
||||||
|
new ResourcePattern(
|
||||||
|
ResourceType.valueOf(values[1]), values[3], PatternType.valueOf(values[2])),
|
||||||
|
new AccessControlEntry(
|
||||||
|
values[0], values[6], AclOperation.valueOf(values[4]), AclPermissionType.valueOf(values[5]))
|
||||||
|
);
|
||||||
|
} catch (IllegalArgumentException enumParseError) {
|
||||||
|
throw new ValidationException("Error parsing enum value in line " + line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Collection<AclBinding> parseCsv(String csvString) {
|
||||||
|
String[] lines = csvString.split(LINE_SEPARATOR);
|
||||||
|
if (lines.length == 0) {
|
||||||
|
throw new ValidationException("Error parsing ACL csv file: no lines in file");
|
||||||
|
}
|
||||||
|
boolean firstLineIsHeader = HEADER.equalsIgnoreCase(lines[0].trim().replace(" ", ""));
|
||||||
|
Set<AclBinding> result = new HashSet<>();
|
||||||
|
for (int i = firstLineIsHeader ? 1 : 0; i < lines.length; i++) {
|
||||||
|
String line = lines[i];
|
||||||
|
if (!line.isBlank()) {
|
||||||
|
AclBinding aclBinding = parseCsvLine(line, i);
|
||||||
|
result.add(aclBinding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.provectus.kafka.ui.service.acl;
|
||||||
|
|
||||||
|
import com.google.common.collect.Sets;
|
||||||
|
import com.provectus.kafka.ui.model.KafkaCluster;
|
||||||
|
import com.provectus.kafka.ui.service.AdminClientService;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.kafka.common.acl.AclBinding;
|
||||||
|
import org.apache.kafka.common.resource.ResourcePatternFilter;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AclsService {
|
||||||
|
|
||||||
|
private final AdminClientService adminClientService;
|
||||||
|
|
||||||
|
public Mono<Void> createAcl(KafkaCluster cluster, AclBinding aclBinding) {
|
||||||
|
var aclString = AclCsv.createAclString(aclBinding);
|
||||||
|
log.info("CREATING ACL: [{}]", aclString);
|
||||||
|
return adminClientService.get(cluster)
|
||||||
|
.flatMap(ac -> ac.createAcls(List.of(aclBinding)))
|
||||||
|
.doOnSuccess(v -> log.info("ACL CREATED: [{}]", aclString));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteAcl(KafkaCluster cluster, AclBinding aclBinding) {
|
||||||
|
var aclString = AclCsv.createAclString(aclBinding);
|
||||||
|
log.info("DELETING ACL: [{}]", aclString);
|
||||||
|
return adminClientService.get(cluster)
|
||||||
|
.flatMap(ac -> ac.deleteAcls(List.of(aclBinding)))
|
||||||
|
.doOnSuccess(v -> log.info("ACL DELETED: [{}]", aclString));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<AclBinding> listAcls(KafkaCluster cluster, ResourcePatternFilter filter) {
|
||||||
|
return adminClientService.get(cluster)
|
||||||
|
.flatMap(c -> c.listAcls(filter))
|
||||||
|
.flatMapIterable(acls -> acls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<String> getAclAsCsvString(KafkaCluster cluster) {
|
||||||
|
return adminClientService.get(cluster)
|
||||||
|
.flatMap(c -> c.listAcls(ResourcePatternFilter.ANY))
|
||||||
|
.map(AclCsv::transformToCsvString);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> syncAclWithAclCsv(KafkaCluster cluster, String csv) {
|
||||||
|
return adminClientService.get(cluster)
|
||||||
|
.flatMap(ac -> ac.listAcls(ResourcePatternFilter.ANY).flatMap(existingAclList -> {
|
||||||
|
var existingSet = Set.copyOf(existingAclList);
|
||||||
|
var newAcls = Set.copyOf(AclCsv.parseCsv(csv));
|
||||||
|
var toDelete = Sets.difference(existingSet, newAcls);
|
||||||
|
var toAdd = Sets.difference(newAcls, existingSet);
|
||||||
|
logAclSyncPlan(cluster, toAdd, toDelete);
|
||||||
|
if (toAdd.isEmpty() && toDelete.isEmpty()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
log.info("Starting new ACLs creation");
|
||||||
|
return ac.createAcls(toAdd)
|
||||||
|
.doOnSuccess(v -> {
|
||||||
|
log.info("{} new ACLs created", toAdd.size());
|
||||||
|
log.info("Starting ACLs deletion");
|
||||||
|
})
|
||||||
|
.then(ac.deleteAcls(toDelete)
|
||||||
|
.doOnSuccess(v -> log.info("{} ACLs deleted", toDelete.size())));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logAclSyncPlan(KafkaCluster cluster, Set<AclBinding> toBeAdded, Set<AclBinding> toBeDeleted) {
|
||||||
|
log.info("'{}' cluster ACL sync plan: ", cluster.getName());
|
||||||
|
if (toBeAdded.isEmpty() && toBeDeleted.isEmpty()) {
|
||||||
|
log.info("Nothing to do, ACL is already in sync");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!toBeAdded.isEmpty()) {
|
||||||
|
log.info("ACLs to be added ({}): ", toBeAdded.size());
|
||||||
|
for (AclBinding aclBinding : toBeAdded) {
|
||||||
|
log.info(" " + AclCsv.createAclString(aclBinding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!toBeDeleted.isEmpty()) {
|
||||||
|
log.info("ACLs to be deleted ({}): ", toBeDeleted.size());
|
||||||
|
for (AclBinding aclBinding : toBeDeleted) {
|
||||||
|
log.info(" " + AclCsv.createAclString(aclBinding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -44,7 +44,7 @@ public class DataMasking {
|
||||||
public static DataMasking create(@Nullable List<ClustersProperties.Masking> config) {
|
public static DataMasking create(@Nullable List<ClustersProperties.Masking> config) {
|
||||||
return new DataMasking(
|
return new DataMasking(
|
||||||
Optional.ofNullable(config).orElse(List.of()).stream().map(property -> {
|
Optional.ofNullable(config).orElse(List.of()).stream().map(property -> {
|
||||||
Preconditions.checkNotNull(property.getType(), "masking type not specifed");
|
Preconditions.checkNotNull(property.getType(), "masking type not specified");
|
||||||
Preconditions.checkArgument(
|
Preconditions.checkArgument(
|
||||||
StringUtils.isNotEmpty(property.getTopicKeysPattern())
|
StringUtils.isNotEmpty(property.getTopicKeysPattern())
|
||||||
|| StringUtils.isNotEmpty(property.getTopicValuesPattern()),
|
|| StringUtils.isNotEmpty(property.getTopicValuesPattern()),
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.provectus.kafka.ui.service.masking.policies;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.config.ClustersProperties;
|
||||||
|
import com.provectus.kafka.ui.exception.ValidationException;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
interface FieldsSelector {
|
||||||
|
|
||||||
|
static FieldsSelector create(ClustersProperties.Masking property) {
|
||||||
|
if (StringUtils.hasText(property.getFieldsNamePattern()) && !CollectionUtils.isEmpty(property.getFields())) {
|
||||||
|
throw new ValidationException("You can't provide both fieldNames & fieldsNamePattern for masking");
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(property.getFieldsNamePattern())) {
|
||||||
|
Pattern pattern = Pattern.compile(property.getFieldsNamePattern());
|
||||||
|
return f -> pattern.matcher(f).matches();
|
||||||
|
}
|
||||||
|
if (!CollectionUtils.isEmpty(property.getFields())) {
|
||||||
|
return f -> property.getFields().contains(f);
|
||||||
|
}
|
||||||
|
//no pattern, no field names - mean all fields should be masked
|
||||||
|
return fieldName -> true;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean shouldBeMasked(String fieldName);
|
||||||
|
|
||||||
|
}
|
|
@ -15,8 +15,8 @@ class Mask extends MaskingPolicy {
|
||||||
|
|
||||||
private final UnaryOperator<String> masker;
|
private final UnaryOperator<String> masker;
|
||||||
|
|
||||||
Mask(List<String> fieldNames, List<String> maskingChars) {
|
Mask(FieldsSelector fieldsSelector, List<String> maskingChars) {
|
||||||
super(fieldNames);
|
super(fieldsSelector);
|
||||||
this.masker = createMasker(maskingChars);
|
this.masker = createMasker(maskingChars);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,22 +38,13 @@ class Mask extends MaskingPolicy {
|
||||||
for (int i = 0; i < input.length(); i++) {
|
for (int i = 0; i < input.length(); i++) {
|
||||||
int cp = input.codePointAt(i);
|
int cp = input.codePointAt(i);
|
||||||
switch (Character.getType(cp)) {
|
switch (Character.getType(cp)) {
|
||||||
case Character.SPACE_SEPARATOR:
|
case Character.SPACE_SEPARATOR,
|
||||||
case Character.LINE_SEPARATOR:
|
Character.LINE_SEPARATOR,
|
||||||
case Character.PARAGRAPH_SEPARATOR:
|
Character.PARAGRAPH_SEPARATOR -> sb.appendCodePoint(cp); // keeping separators as-is
|
||||||
sb.appendCodePoint(cp); // keeping separators as-is
|
case Character.UPPERCASE_LETTER -> sb.append(maskingChars.get(0));
|
||||||
break;
|
case Character.LOWERCASE_LETTER -> sb.append(maskingChars.get(1));
|
||||||
case Character.UPPERCASE_LETTER:
|
case Character.DECIMAL_DIGIT_NUMBER -> sb.append(maskingChars.get(2));
|
||||||
sb.append(maskingChars.get(0));
|
default -> sb.append(maskingChars.get(3));
|
||||||
break;
|
|
||||||
case Character.LOWERCASE_LETTER:
|
|
||||||
sb.append(maskingChars.get(1));
|
|
||||||
break;
|
|
||||||
case Character.DECIMAL_DIGIT_NUMBER:
|
|
||||||
sb.append(maskingChars.get(2));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
sb.append(maskingChars.get(3));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
|
|
|
@ -2,46 +2,36 @@ package com.provectus.kafka.ui.service.masking.policies;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.node.ContainerNode;
|
import com.fasterxml.jackson.databind.node.ContainerNode;
|
||||||
import com.provectus.kafka.ui.config.ClustersProperties;
|
import com.provectus.kafka.ui.config.ClustersProperties;
|
||||||
import java.util.List;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public abstract class MaskingPolicy {
|
public abstract class MaskingPolicy {
|
||||||
|
|
||||||
|
|
||||||
public static MaskingPolicy create(ClustersProperties.Masking property) {
|
public static MaskingPolicy create(ClustersProperties.Masking property) {
|
||||||
List<String> fields = property.getFields() == null
|
FieldsSelector fieldsSelector = FieldsSelector.create(property);
|
||||||
? List.of() // empty list means that policy will be applied to all fields
|
return switch (property.getType()) {
|
||||||
: property.getFields();
|
case REMOVE -> new Remove(fieldsSelector);
|
||||||
switch (property.getType()) {
|
case REPLACE -> new Replace(
|
||||||
case REMOVE:
|
fieldsSelector,
|
||||||
return new Remove(fields);
|
property.getReplacement() == null
|
||||||
case REPLACE:
|
? Replace.DEFAULT_REPLACEMENT
|
||||||
return new Replace(
|
: property.getReplacement()
|
||||||
fields,
|
);
|
||||||
property.getReplacement() == null
|
case MASK -> new Mask(
|
||||||
? Replace.DEFAULT_REPLACEMENT
|
fieldsSelector,
|
||||||
: property.getReplacement()
|
property.getMaskingCharsReplacement() == null
|
||||||
);
|
? Mask.DEFAULT_PATTERN
|
||||||
case MASK:
|
: property.getMaskingCharsReplacement()
|
||||||
return new Mask(
|
);
|
||||||
fields,
|
};
|
||||||
property.getPattern() == null
|
|
||||||
? Mask.DEFAULT_PATTERN
|
|
||||||
: property.getPattern()
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
throw new IllegalStateException("Unknown policy type: " + property.getType());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//----------------------------------------------------------------
|
//----------------------------------------------------------------
|
||||||
|
|
||||||
// empty list means policy will be applied to all fields
|
private final FieldsSelector fieldsSelector;
|
||||||
private final List<String> fieldNames;
|
|
||||||
|
|
||||||
protected boolean fieldShouldBeMasked(String fieldName) {
|
protected boolean fieldShouldBeMasked(String fieldName) {
|
||||||
return fieldNames.isEmpty() || fieldNames.contains(fieldName);
|
return fieldsSelector.shouldBeMasked(fieldName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract ContainerNode<?> applyToJsonContainer(ContainerNode<?> node);
|
public abstract ContainerNode<?> applyToJsonContainer(ContainerNode<?> node);
|
||||||
|
|
|
@ -4,12 +4,12 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
import com.fasterxml.jackson.databind.node.ContainerNode;
|
import com.fasterxml.jackson.databind.node.ContainerNode;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
class Remove extends MaskingPolicy {
|
class Remove extends MaskingPolicy {
|
||||||
|
|
||||||
Remove(List<String> fieldNames) {
|
Remove(FieldsSelector fieldsSelector) {
|
||||||
super(fieldNames);
|
super(fieldsSelector);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.node.ContainerNode;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import com.fasterxml.jackson.databind.node.TextNode;
|
import com.fasterxml.jackson.databind.node.TextNode;
|
||||||
import com.google.common.base.Preconditions;
|
import com.google.common.base.Preconditions;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
class Replace extends MaskingPolicy {
|
class Replace extends MaskingPolicy {
|
||||||
|
|
||||||
|
@ -14,8 +13,8 @@ class Replace extends MaskingPolicy {
|
||||||
|
|
||||||
private final String replacement;
|
private final String replacement;
|
||||||
|
|
||||||
Replace(List<String> fieldNames, String replacementString) {
|
Replace(FieldsSelector fieldsSelector, String replacementString) {
|
||||||
super(fieldNames);
|
super(fieldsSelector);
|
||||||
this.replacement = Preconditions.checkNotNull(replacementString);
|
this.replacement = Preconditions.checkNotNull(replacementString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -61,7 +61,9 @@ class JmxSslSocketFactory extends javax.net.ssl.SSLSocketFactory {
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("----------------------------------");
|
log.error("----------------------------------");
|
||||||
log.error("SSL can't be enabled for JMX retrieval. "
|
log.error("SSL can't be enabled for JMX retrieval. "
|
||||||
+ "Make sure your java app run with '--add-opens java.rmi/javax.rmi.ssl=ALL-UNNAMED' arg.", e);
|
+ "Make sure your java app run with '--add-opens java.rmi/javax.rmi.ssl=ALL-UNNAMED' arg. Err: {}",
|
||||||
|
e.getMessage());
|
||||||
|
log.trace("SSL can't be enabled for JMX retrieval", e);
|
||||||
log.error("----------------------------------");
|
log.error("----------------------------------");
|
||||||
}
|
}
|
||||||
SSL_JMX_SUPPORTED = sslJmxSupported;
|
SSL_JMX_SUPPORTED = sslJmxSupported;
|
||||||
|
|
|
@ -12,6 +12,7 @@ import com.provectus.kafka.ui.model.rbac.AccessContext;
|
||||||
import com.provectus.kafka.ui.model.rbac.Permission;
|
import com.provectus.kafka.ui.model.rbac.Permission;
|
||||||
import com.provectus.kafka.ui.model.rbac.Resource;
|
import com.provectus.kafka.ui.model.rbac.Resource;
|
||||||
import com.provectus.kafka.ui.model.rbac.Role;
|
import com.provectus.kafka.ui.model.rbac.Role;
|
||||||
|
import com.provectus.kafka.ui.model.rbac.Subject;
|
||||||
import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
|
import com.provectus.kafka.ui.model.rbac.permission.ConnectAction;
|
||||||
import com.provectus.kafka.ui.model.rbac.permission.ConsumerGroupAction;
|
import com.provectus.kafka.ui.model.rbac.permission.ConsumerGroupAction;
|
||||||
import com.provectus.kafka.ui.model.rbac.permission.SchemaAction;
|
import com.provectus.kafka.ui.model.rbac.permission.SchemaAction;
|
||||||
|
@ -19,11 +20,11 @@ import com.provectus.kafka.ui.model.rbac.permission.TopicAction;
|
||||||
import com.provectus.kafka.ui.service.rbac.extractor.CognitoAuthorityExtractor;
|
import com.provectus.kafka.ui.service.rbac.extractor.CognitoAuthorityExtractor;
|
||||||
import com.provectus.kafka.ui.service.rbac.extractor.GithubAuthorityExtractor;
|
import com.provectus.kafka.ui.service.rbac.extractor.GithubAuthorityExtractor;
|
||||||
import com.provectus.kafka.ui.service.rbac.extractor.GoogleAuthorityExtractor;
|
import com.provectus.kafka.ui.service.rbac.extractor.GoogleAuthorityExtractor;
|
||||||
import com.provectus.kafka.ui.service.rbac.extractor.LdapAuthorityExtractor;
|
|
||||||
import com.provectus.kafka.ui.service.rbac.extractor.ProviderAuthorityExtractor;
|
import com.provectus.kafka.ui.service.rbac.extractor.ProviderAuthorityExtractor;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
@ -34,6 +35,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.collections.CollectionUtils;
|
import org.apache.commons.collections.CollectionUtils;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||||
import org.springframework.security.core.context.SecurityContext;
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
@ -50,10 +52,11 @@ public class AccessControlService {
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private final InMemoryReactiveClientRegistrationRepository clientRegistrationRepository;
|
private final InMemoryReactiveClientRegistrationRepository clientRegistrationRepository;
|
||||||
|
private final RoleBasedAccessControlProperties properties;
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
private boolean rbacEnabled = false;
|
private boolean rbacEnabled = false;
|
||||||
private Set<ProviderAuthorityExtractor> extractors = Collections.emptySet();
|
private Set<ProviderAuthorityExtractor> oauthExtractors = Collections.emptySet();
|
||||||
private final RoleBasedAccessControlProperties properties;
|
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
|
@ -63,21 +66,26 @@ public class AccessControlService {
|
||||||
}
|
}
|
||||||
rbacEnabled = true;
|
rbacEnabled = true;
|
||||||
|
|
||||||
this.extractors = properties.getRoles()
|
this.oauthExtractors = properties.getRoles()
|
||||||
.stream()
|
.stream()
|
||||||
.map(role -> role.getSubjects()
|
.map(role -> role.getSubjects()
|
||||||
.stream()
|
.stream()
|
||||||
.map(provider -> switch (provider.getProvider()) {
|
.map(Subject::getProvider)
|
||||||
|
.distinct()
|
||||||
|
.map(provider -> switch (provider) {
|
||||||
case OAUTH_COGNITO -> new CognitoAuthorityExtractor();
|
case OAUTH_COGNITO -> new CognitoAuthorityExtractor();
|
||||||
case OAUTH_GOOGLE -> new GoogleAuthorityExtractor();
|
case OAUTH_GOOGLE -> new GoogleAuthorityExtractor();
|
||||||
case OAUTH_GITHUB -> new GithubAuthorityExtractor();
|
case OAUTH_GITHUB -> new GithubAuthorityExtractor();
|
||||||
case LDAP, LDAP_AD -> new LdapAuthorityExtractor();
|
default -> null;
|
||||||
}).collect(Collectors.toSet()))
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toSet()))
|
||||||
.flatMap(Set::stream)
|
.flatMap(Set::stream)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
if ((clientRegistrationRepository == null || !clientRegistrationRepository.iterator().hasNext())
|
if (!properties.getRoles().isEmpty()
|
||||||
&& !properties.getRoles().isEmpty()) {
|
&& "oauth2".equalsIgnoreCase(environment.getProperty("auth.type"))
|
||||||
|
&& (clientRegistrationRepository == null || !clientRegistrationRepository.iterator().hasNext())) {
|
||||||
log.error("Roles are configured but no authentication methods are present. Authentication might fail.");
|
log.error("Roles are configured but no authentication methods are present. Authentication might fail.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -354,8 +362,8 @@ public class AccessControlService {
|
||||||
return isAccessible(Resource.KSQL, null, user, context, requiredActions);
|
return isAccessible(Resource.KSQL, null, user, context, requiredActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<ProviderAuthorityExtractor> getExtractors() {
|
public Set<ProviderAuthorityExtractor> getOauthExtractors() {
|
||||||
return extractors;
|
return oauthExtractors;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Role> getRoles() {
|
public List<Role> getRoles() {
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
package com.provectus.kafka.ui.service.rbac.extractor;
|
|
||||||
|
|
||||||
import com.provectus.kafka.ui.service.rbac.AccessControlService;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
public class LdapAuthorityExtractor implements ProviderAuthorityExtractor {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isApplicable(String provider) {
|
|
||||||
return false; // TODO #2752
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Mono<Set<String>> extract(AccessControlService acs, Object value, Map<String, Object> additionalParams) {
|
|
||||||
return Mono.just(Collections.emptySet()); // TODO #2752
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.provectus.kafka.ui.service.rbac.extractor;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.config.auth.LdapProperties;
|
||||||
|
import com.provectus.kafka.ui.model.rbac.Role;
|
||||||
|
import com.provectus.kafka.ui.model.rbac.provider.Provider;
|
||||||
|
import com.provectus.kafka.ui.service.rbac.AccessControlService;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.ldap.core.DirContextOperations;
|
||||||
|
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class RbacLdapAuthoritiesExtractor extends DefaultLdapAuthoritiesPopulator {
|
||||||
|
|
||||||
|
private final AccessControlService acs;
|
||||||
|
private final LdapProperties props;
|
||||||
|
|
||||||
|
private final Function<Map<String, List<String>>, GrantedAuthority> authorityMapper = (record) -> {
|
||||||
|
String role = record.get(getGroupRoleAttribute()).get(0);
|
||||||
|
return new SimpleGrantedAuthority(role);
|
||||||
|
};
|
||||||
|
|
||||||
|
public RbacLdapAuthoritiesExtractor(ApplicationContext context) {
|
||||||
|
super(context.getBean(BaseLdapPathContextSource.class), null);
|
||||||
|
this.acs = context.getBean(AccessControlService.class);
|
||||||
|
this.props = context.getBean(LdapProperties.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<GrantedAuthority> getAdditionalRoles(DirContextOperations user, String username) {
|
||||||
|
return acs.getRoles()
|
||||||
|
.stream()
|
||||||
|
.map(Role::getSubjects)
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.filter(s -> s.getProvider().equals(Provider.LDAP))
|
||||||
|
.filter(s -> s.getType().equals("group"))
|
||||||
|
.flatMap(subject -> getRoles(subject.getValue(), user.getNameInNamespace(), username).stream())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<GrantedAuthority> getRoles(String groupSearchBase, String userDn, String username) {
|
||||||
|
Assert.notNull(groupSearchBase, "groupSearchBase is empty");
|
||||||
|
|
||||||
|
log.trace(
|
||||||
|
"Searching for roles for user [{}] with DN [{}], groupRoleAttribute [{}] and filter [{}] in search base [{}]",
|
||||||
|
username, userDn, props.getGroupRoleAttribute(), getGroupSearchFilter(), groupSearchBase);
|
||||||
|
|
||||||
|
var ldapTemplate = getLdapTemplate();
|
||||||
|
ldapTemplate.setIgnoreNameNotFoundException(true);
|
||||||
|
|
||||||
|
Set<Map<String, List<String>>> userRoles = ldapTemplate.searchForMultipleAttributeValues(
|
||||||
|
groupSearchBase, getGroupSearchFilter(), new String[] {userDn, username},
|
||||||
|
new String[] {props.getGroupRoleAttribute()});
|
||||||
|
|
||||||
|
return userRoles.stream()
|
||||||
|
.map(authorityMapper)
|
||||||
|
.peek(a -> log.debug("Mapped role [{}] for user [{}]", a, username))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -90,6 +90,7 @@ public class DynamicConfigOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public PropertiesStructure getCurrentProperties() {
|
public PropertiesStructure getCurrentProperties() {
|
||||||
|
checkIfDynamicConfigEnabled();
|
||||||
return PropertiesStructure.builder()
|
return PropertiesStructure.builder()
|
||||||
.kafka(getNullableBean(ClustersProperties.class))
|
.kafka(getNullableBean(ClustersProperties.class))
|
||||||
.rbac(getNullableBean(RoleBasedAccessControlProperties.class))
|
.rbac(getNullableBean(RoleBasedAccessControlProperties.class))
|
||||||
|
@ -112,11 +113,7 @@ public class DynamicConfigOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void persist(PropertiesStructure properties) {
|
public void persist(PropertiesStructure properties) {
|
||||||
if (!dynamicConfigEnabled()) {
|
checkIfDynamicConfigEnabled();
|
||||||
throw new ValidationException(
|
|
||||||
"Dynamic config change is not allowed. "
|
|
||||||
+ "Set dynamic.config.enabled property to 'true' to enabled it.");
|
|
||||||
}
|
|
||||||
properties.initAndValidate();
|
properties.initAndValidate();
|
||||||
|
|
||||||
String yaml = serializeToYaml(properties);
|
String yaml = serializeToYaml(properties);
|
||||||
|
@ -124,8 +121,9 @@ public class DynamicConfigOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Path> uploadConfigRelatedFile(FilePart file) {
|
public Mono<Path> uploadConfigRelatedFile(FilePart file) {
|
||||||
String targetDirStr = (String) ctx.getEnvironment().getSystemEnvironment()
|
checkIfDynamicConfigEnabled();
|
||||||
.getOrDefault(CONFIG_RELATED_UPLOADS_DIR_PROPERTY, CONFIG_RELATED_UPLOADS_DIR_DEFAULT);
|
String targetDirStr = ctx.getEnvironment()
|
||||||
|
.getProperty(CONFIG_RELATED_UPLOADS_DIR_PROPERTY, CONFIG_RELATED_UPLOADS_DIR_DEFAULT);
|
||||||
|
|
||||||
Path targetDir = Path.of(targetDirStr);
|
Path targetDir = Path.of(targetDirStr);
|
||||||
if (!Files.exists(targetDir)) {
|
if (!Files.exists(targetDir)) {
|
||||||
|
@ -149,6 +147,14 @@ public class DynamicConfigOperations {
|
||||||
.onErrorMap(th -> new FileUploadException(targetFilePath, th));
|
.onErrorMap(th -> new FileUploadException(targetFilePath, th));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void checkIfDynamicConfigEnabled() {
|
||||||
|
if (!dynamicConfigEnabled()) {
|
||||||
|
throw new ValidationException(
|
||||||
|
"Dynamic config change is not allowed. "
|
||||||
|
+ "Set dynamic.config.enabled property to 'true' to enabled it.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
private void writeYamlToFile(String yaml, Path path) {
|
private void writeYamlToFile(String yaml, Path path) {
|
||||||
if (Files.isDirectory(path)) {
|
if (Files.isDirectory(path)) {
|
||||||
|
|
|
@ -1,24 +1,21 @@
|
||||||
package com.provectus.kafka.ui.util;
|
package com.provectus.kafka.ui.util;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
public final class KafkaVersion {
|
public final class KafkaVersion {
|
||||||
|
|
||||||
private KafkaVersion() {
|
private KafkaVersion() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static float parse(String version) throws NumberFormatException {
|
public static Optional<Float> parse(String version) throws NumberFormatException {
|
||||||
log.trace("Parsing cluster version [{}]", version);
|
|
||||||
try {
|
try {
|
||||||
final String[] parts = version.split("\\.");
|
final String[] parts = version.split("\\.");
|
||||||
if (parts.length > 2) {
|
if (parts.length > 2) {
|
||||||
version = parts[0] + "." + parts[1];
|
version = parts[0] + "." + parts[1];
|
||||||
}
|
}
|
||||||
return Float.parseFloat(version.split("-")[0]);
|
return Optional.of(Float.parseFloat(version.split("-")[0]));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Conversion clusterVersion [{}] to float value failed", version, e);
|
return Optional.empty();
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package com.provectus.kafka.ui;
|
||||||
|
|
||||||
import com.provectus.kafka.ui.container.KafkaConnectContainer;
|
import com.provectus.kafka.ui.container.KafkaConnectContainer;
|
||||||
import com.provectus.kafka.ui.container.SchemaRegistryContainer;
|
import com.provectus.kafka.ui.container.SchemaRegistryContainer;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
import org.apache.kafka.clients.admin.AdminClient;
|
import org.apache.kafka.clients.admin.AdminClient;
|
||||||
|
@ -9,6 +10,7 @@ import org.apache.kafka.clients.admin.AdminClientConfig;
|
||||||
import org.apache.kafka.clients.admin.NewTopic;
|
import org.apache.kafka.clients.admin.NewTopic;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.junit.jupiter.api.function.ThrowingConsumer;
|
import org.junit.jupiter.api.function.ThrowingConsumer;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
@ -47,6 +49,9 @@ public abstract class AbstractIntegrationTest {
|
||||||
.dependsOn(kafka)
|
.dependsOn(kafka)
|
||||||
.dependsOn(schemaRegistry);
|
.dependsOn(schemaRegistry);
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
public static Path tmpDir;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
kafka.start();
|
kafka.start();
|
||||||
schemaRegistry.start();
|
schemaRegistry.start();
|
||||||
|
@ -76,6 +81,9 @@ public abstract class AbstractIntegrationTest {
|
||||||
System.setProperty("kafka.clusters.1.schemaRegistry", schemaRegistry.getUrl());
|
System.setProperty("kafka.clusters.1.schemaRegistry", schemaRegistry.getUrl());
|
||||||
System.setProperty("kafka.clusters.1.kafkaConnect.0.name", "kafka-connect");
|
System.setProperty("kafka.clusters.1.kafkaConnect.0.name", "kafka-connect");
|
||||||
System.setProperty("kafka.clusters.1.kafkaConnect.0.address", kafkaConnect.getTarget());
|
System.setProperty("kafka.clusters.1.kafkaConnect.0.address", kafkaConnect.getTarget());
|
||||||
|
|
||||||
|
System.setProperty("dynamic.config.enabled", "true");
|
||||||
|
System.setProperty("config.related.uploads.dir", tmpDir.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.provectus.kafka.ui.controller;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.AbstractIntegrationTest;
|
||||||
|
import com.provectus.kafka.ui.model.UploadedFileInfoDTO;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
|
||||||
|
class ApplicationConfigControllerTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WebTestClient webTestClient;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUpload() throws IOException {
|
||||||
|
var fileToUpload = new ClassPathResource("/fileForUploadTest.txt", this.getClass());
|
||||||
|
|
||||||
|
UploadedFileInfoDTO result = webTestClient
|
||||||
|
.post()
|
||||||
|
.uri("/api/config/relatedfiles")
|
||||||
|
.bodyValue(generateBody(fileToUpload))
|
||||||
|
.exchange()
|
||||||
|
.expectStatus()
|
||||||
|
.isOk()
|
||||||
|
.expectBody(UploadedFileInfoDTO.class)
|
||||||
|
.returnResult()
|
||||||
|
.getResponseBody();
|
||||||
|
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getLocation()).isNotNull();
|
||||||
|
assertThat(Path.of(result.getLocation()))
|
||||||
|
.hasSameBinaryContentAs(fileToUpload.getFile().toPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
private MultiValueMap<String, HttpEntity<?>> generateBody(ClassPathResource resource) {
|
||||||
|
MultipartBodyBuilder builder = new MultipartBodyBuilder();
|
||||||
|
builder.part("file", resource);
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.provectus.kafka.ui.service.acl;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.exception.ValidationException;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.kafka.common.acl.AccessControlEntry;
|
||||||
|
import org.apache.kafka.common.acl.AclBinding;
|
||||||
|
import org.apache.kafka.common.acl.AclOperation;
|
||||||
|
import org.apache.kafka.common.acl.AclPermissionType;
|
||||||
|
import org.apache.kafka.common.resource.PatternType;
|
||||||
|
import org.apache.kafka.common.resource.ResourcePattern;
|
||||||
|
import org.apache.kafka.common.resource.ResourceType;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
||||||
|
class AclCsvTest {
|
||||||
|
|
||||||
|
private static final List<AclBinding> TEST_BINDINGS = List.of(
|
||||||
|
new AclBinding(
|
||||||
|
new ResourcePattern(ResourceType.TOPIC, "*", PatternType.LITERAL),
|
||||||
|
new AccessControlEntry("User:test1", "*", AclOperation.READ, AclPermissionType.ALLOW)),
|
||||||
|
new AclBinding(
|
||||||
|
new ResourcePattern(ResourceType.GROUP, "group1", PatternType.PREFIXED),
|
||||||
|
new AccessControlEntry("User:test2", "localhost", AclOperation.DESCRIBE, AclPermissionType.DENY))
|
||||||
|
);
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {
|
||||||
|
"Principal,ResourceType, PatternType, ResourceName,Operation,PermissionType,Host\n"
|
||||||
|
+ "User:test1,TOPIC,LITERAL,*,READ,ALLOW,*\n"
|
||||||
|
+ "User:test2,GROUP,PREFIXED,group1,DESCRIBE,DENY,localhost",
|
||||||
|
|
||||||
|
//without header
|
||||||
|
"User:test1,TOPIC,LITERAL,*,READ,ALLOW,*\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "User:test2,GROUP,PREFIXED,group1,DESCRIBE,DENY,localhost"
|
||||||
|
+ "\n"
|
||||||
|
})
|
||||||
|
void parsesValidInputCsv(String csvString) {
|
||||||
|
Collection<AclBinding> parsed = AclCsv.parseCsv(csvString);
|
||||||
|
assertThat(parsed).containsExactlyInAnyOrderElementsOf(TEST_BINDINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {
|
||||||
|
// columns > 7
|
||||||
|
"User:test1,TOPIC,LITERAL,*,READ,ALLOW,*,1,2,3,4",
|
||||||
|
// columns < 7
|
||||||
|
"User:test1,TOPIC,LITERAL,*",
|
||||||
|
// enum values are illegal
|
||||||
|
"User:test1,ILLEGAL,LITERAL,*,READ,ALLOW,*",
|
||||||
|
"User:test1,TOPIC,LITERAL,*,READ,ILLEGAL,*"
|
||||||
|
})
|
||||||
|
void throwsExceptionForInvalidInputCsv(String csvString) {
|
||||||
|
assertThatThrownBy(() -> AclCsv.parseCsv(csvString))
|
||||||
|
.isInstanceOf(ValidationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transformAndParseUseSameFormat() {
|
||||||
|
String csv = AclCsv.transformToCsvString(TEST_BINDINGS);
|
||||||
|
Collection<AclBinding> parsedBindings = AclCsv.parseCsv(csv);
|
||||||
|
assertThat(parsedBindings).containsExactlyInAnyOrderElementsOf(TEST_BINDINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
package com.provectus.kafka.ui.service.acl;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.model.KafkaCluster;
|
||||||
|
import com.provectus.kafka.ui.service.AdminClientService;
|
||||||
|
import com.provectus.kafka.ui.service.ReactiveAdminClient;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.kafka.common.acl.AccessControlEntry;
|
||||||
|
import org.apache.kafka.common.acl.AclBinding;
|
||||||
|
import org.apache.kafka.common.acl.AclOperation;
|
||||||
|
import org.apache.kafka.common.acl.AclPermissionType;
|
||||||
|
import org.apache.kafka.common.resource.PatternType;
|
||||||
|
import org.apache.kafka.common.resource.ResourcePattern;
|
||||||
|
import org.apache.kafka.common.resource.ResourcePatternFilter;
|
||||||
|
import org.apache.kafka.common.resource.ResourceType;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
class AclsServiceTest {
|
||||||
|
|
||||||
|
private static final KafkaCluster CLUSTER = KafkaCluster.builder().build();
|
||||||
|
|
||||||
|
private final ReactiveAdminClient adminClientMock = mock(ReactiveAdminClient.class);
|
||||||
|
private final AdminClientService adminClientService = mock(AdminClientService.class);
|
||||||
|
|
||||||
|
private final AclsService aclsService = new AclsService(adminClientService);
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void initMocks() {
|
||||||
|
when(adminClientService.get(CLUSTER)).thenReturn(Mono.just(adminClientMock));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSyncAclWithAclCsv() {
|
||||||
|
var existingBinding1 = new AclBinding(
|
||||||
|
new ResourcePattern(ResourceType.TOPIC, "*", PatternType.LITERAL),
|
||||||
|
new AccessControlEntry("User:test1", "*", AclOperation.READ, AclPermissionType.ALLOW));
|
||||||
|
|
||||||
|
var existingBinding2 = new AclBinding(
|
||||||
|
new ResourcePattern(ResourceType.GROUP, "group1", PatternType.PREFIXED),
|
||||||
|
new AccessControlEntry("User:test2", "localhost", AclOperation.DESCRIBE, AclPermissionType.DENY));
|
||||||
|
|
||||||
|
var newBindingToBeAdded = new AclBinding(
|
||||||
|
new ResourcePattern(ResourceType.GROUP, "groupNew", PatternType.PREFIXED),
|
||||||
|
new AccessControlEntry("User:test3", "localhost", AclOperation.DESCRIBE, AclPermissionType.DENY));
|
||||||
|
|
||||||
|
when(adminClientMock.listAcls(ResourcePatternFilter.ANY))
|
||||||
|
.thenReturn(Mono.just(List.of(existingBinding1, existingBinding2)));
|
||||||
|
|
||||||
|
ArgumentCaptor<?> createdCaptor = ArgumentCaptor.forClass(Collection.class);
|
||||||
|
when(adminClientMock.createAcls((Collection<AclBinding>) createdCaptor.capture()))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
ArgumentCaptor<?> deletedCaptor = ArgumentCaptor.forClass(Collection.class);
|
||||||
|
when(adminClientMock.deleteAcls((Collection<AclBinding>) deletedCaptor.capture()))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
aclsService.syncAclWithAclCsv(
|
||||||
|
CLUSTER,
|
||||||
|
"Principal,ResourceType, PatternType, ResourceName,Operation,PermissionType,Host\n"
|
||||||
|
+ "User:test1,TOPIC,LITERAL,*,READ,ALLOW,*\n"
|
||||||
|
+ "User:test3,GROUP,PREFIXED,groupNew,DESCRIBE,DENY,localhost"
|
||||||
|
).block();
|
||||||
|
|
||||||
|
Collection<AclBinding> createdBindings = (Collection<AclBinding>) createdCaptor.getValue();
|
||||||
|
assertThat(createdBindings)
|
||||||
|
.hasSize(1)
|
||||||
|
.contains(newBindingToBeAdded);
|
||||||
|
|
||||||
|
Collection<AclBinding> deletedBindings = (Collection<AclBinding>) deletedCaptor.getValue();
|
||||||
|
assertThat(deletedBindings)
|
||||||
|
.hasSize(1)
|
||||||
|
.contains(existingBinding2);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.provectus.kafka.ui.service.masking.policies;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import com.provectus.kafka.ui.config.ClustersProperties;
|
||||||
|
import com.provectus.kafka.ui.exception.ValidationException;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class FieldsSelectorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectsFieldsDueToProvidedPattern() {
|
||||||
|
var properties = new ClustersProperties.Masking();
|
||||||
|
properties.setFieldsNamePattern("f1|f2");
|
||||||
|
|
||||||
|
var selector = FieldsSelector.create(properties);
|
||||||
|
assertThat(selector.shouldBeMasked("f1")).isTrue();
|
||||||
|
assertThat(selector.shouldBeMasked("f2")).isTrue();
|
||||||
|
assertThat(selector.shouldBeMasked("doesNotMatchPattern")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectsFieldsDueToProvidedFieldNames() {
|
||||||
|
var properties = new ClustersProperties.Masking();
|
||||||
|
properties.setFields(List.of("f1", "f2"));
|
||||||
|
|
||||||
|
var selector = FieldsSelector.create(properties);
|
||||||
|
assertThat(selector.shouldBeMasked("f1")).isTrue();
|
||||||
|
assertThat(selector.shouldBeMasked("f2")).isTrue();
|
||||||
|
assertThat(selector.shouldBeMasked("notInAList")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectAllFieldsIfNoPatternAndNoNamesProvided() {
|
||||||
|
var properties = new ClustersProperties.Masking();
|
||||||
|
|
||||||
|
var selector = FieldsSelector.create(properties);
|
||||||
|
assertThat(selector.shouldBeMasked("anyPropertyName")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void throwsExceptionIfBothFieldListAndPatternProvided() {
|
||||||
|
var properties = new ClustersProperties.Masking();
|
||||||
|
properties.setFieldsNamePattern("f1|f2");
|
||||||
|
properties.setFields(List.of("f3", "f4"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> FieldsSelector.create(properties))
|
||||||
|
.isInstanceOf(ValidationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -15,35 +15,35 @@ import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
|
||||||
class MaskTest {
|
class MaskTest {
|
||||||
|
|
||||||
private static final List<String> TARGET_FIELDS = List.of("id", "name");
|
private static final FieldsSelector FIELDS_SELECTOR = fieldName -> List.of("id", "name").contains(fieldName);
|
||||||
private static final List<String> PATTERN = List.of("X", "x", "n", "-");
|
private static final List<String> PATTERN = List.of("X", "x", "n", "-");
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource
|
@MethodSource
|
||||||
void testApplyToJsonContainer(List<String> fields, ContainerNode<?> original, ContainerNode<?> expected) {
|
void testApplyToJsonContainer(FieldsSelector selector, ContainerNode<?> original, ContainerNode<?> expected) {
|
||||||
Mask policy = new Mask(fields, PATTERN);
|
Mask policy = new Mask(selector, PATTERN);
|
||||||
assertThat(policy.applyToJsonContainer(original)).isEqualTo(expected);
|
assertThat(policy.applyToJsonContainer(original)).isEqualTo(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Stream<Arguments> testApplyToJsonContainer() {
|
private static Stream<Arguments> testApplyToJsonContainer() {
|
||||||
return Stream.of(
|
return Stream.of(
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
TARGET_FIELDS,
|
FIELDS_SELECTOR,
|
||||||
parse("{ \"id\": 123, \"name\": { \"first\": \"James\", \"surname\": \"Bond777!\"}}"),
|
parse("{ \"id\": 123, \"name\": { \"first\": \"James\", \"surname\": \"Bond777!\"}}"),
|
||||||
parse("{ \"id\": \"nnn\", \"name\": { \"first\": \"Xxxxx\", \"surname\": \"Xxxxnnn-\"}}")
|
parse("{ \"id\": \"nnn\", \"name\": { \"first\": \"Xxxxx\", \"surname\": \"Xxxxnnn-\"}}")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
TARGET_FIELDS,
|
FIELDS_SELECTOR,
|
||||||
parse("[{ \"id\": 123, \"f2\": 234}, { \"name\": \"1.2\", \"f2\": 345} ]"),
|
parse("[{ \"id\": 123, \"f2\": 234}, { \"name\": \"1.2\", \"f2\": 345} ]"),
|
||||||
parse("[{ \"id\": \"nnn\", \"f2\": 234}, { \"name\": \"n-n\", \"f2\": 345} ]")
|
parse("[{ \"id\": \"nnn\", \"f2\": 234}, { \"name\": \"n-n\", \"f2\": 345} ]")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
TARGET_FIELDS,
|
FIELDS_SELECTOR,
|
||||||
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"),
|
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"),
|
||||||
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Xxxxnnn-\"}}")
|
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Xxxxnnn-\"}}")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
List.of(),
|
(FieldsSelector) (fieldName -> true),
|
||||||
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"),
|
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"),
|
||||||
parse("{ \"outer\": { \"f1\": \"Xxxxx\", \"name\": \"Xxxxnnn-\"}}")
|
parse("{ \"outer\": { \"f1\": \"Xxxxx\", \"name\": \"Xxxxnnn-\"}}")
|
||||||
)
|
)
|
||||||
|
@ -57,7 +57,7 @@ class MaskTest {
|
||||||
"null, xxxx"
|
"null, xxxx"
|
||||||
})
|
})
|
||||||
void testApplyToString(String original, String expected) {
|
void testApplyToString(String original, String expected) {
|
||||||
Mask policy = new Mask(List.of(), PATTERN);
|
Mask policy = new Mask(fieldName -> true, PATTERN);
|
||||||
assertThat(policy.applyToString(original)).isEqualTo(expected);
|
assertThat(policy.applyToString(original)).isEqualTo(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,39 +15,39 @@ import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
|
||||||
class RemoveTest {
|
class RemoveTest {
|
||||||
|
|
||||||
private static final List<String> TARGET_FIELDS = List.of("id", "name");
|
private static final FieldsSelector FIELDS_SELECTOR = fieldName -> List.of("id", "name").contains(fieldName);
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource
|
@MethodSource
|
||||||
void testApplyToJsonContainer(List<String> fields, ContainerNode<?> original, ContainerNode<?> expected) {
|
void testApplyToJsonContainer(FieldsSelector fieldsSelector, ContainerNode<?> original, ContainerNode<?> expected) {
|
||||||
var policy = new Remove(fields);
|
var policy = new Remove(fieldsSelector);
|
||||||
assertThat(policy.applyToJsonContainer(original)).isEqualTo(expected);
|
assertThat(policy.applyToJsonContainer(original)).isEqualTo(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Stream<Arguments> testApplyToJsonContainer() {
|
private static Stream<Arguments> testApplyToJsonContainer() {
|
||||||
return Stream.of(
|
return Stream.of(
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
TARGET_FIELDS,
|
FIELDS_SELECTOR,
|
||||||
parse("{ \"id\": 123, \"name\": { \"first\": \"James\", \"surname\": \"Bond777!\"}}"),
|
parse("{ \"id\": 123, \"name\": { \"first\": \"James\", \"surname\": \"Bond777!\"}}"),
|
||||||
parse("{}")
|
parse("{}")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
TARGET_FIELDS,
|
FIELDS_SELECTOR,
|
||||||
parse("[{ \"id\": 123, \"f2\": 234}, { \"name\": \"1.2\", \"f2\": 345} ]"),
|
parse("[{ \"id\": 123, \"f2\": 234}, { \"name\": \"1.2\", \"f2\": 345} ]"),
|
||||||
parse("[{ \"f2\": 234}, { \"f2\": 345} ]")
|
parse("[{ \"f2\": 234}, { \"f2\": 345} ]")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
TARGET_FIELDS,
|
FIELDS_SELECTOR,
|
||||||
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"),
|
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"),
|
||||||
parse("{ \"outer\": { \"f1\": \"James\"}}")
|
parse("{ \"outer\": { \"f1\": \"James\"}}")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
List.of(),
|
(FieldsSelector) (fieldName -> true),
|
||||||
parse("{ \"outer\": { \"f1\": \"v1\", \"f2\": \"v2\", \"inner\" : {\"if1\": \"iv1\"}}}"),
|
parse("{ \"outer\": { \"f1\": \"v1\", \"f2\": \"v2\", \"inner\" : {\"if1\": \"iv1\"}}}"),
|
||||||
parse("{}")
|
parse("{}")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
List.of(),
|
(FieldsSelector) (fieldName -> true),
|
||||||
parse("[{ \"f1\": 123}, { \"f2\": \"1.2\"} ]"),
|
parse("[{ \"f1\": 123}, { \"f2\": \"1.2\"} ]"),
|
||||||
parse("[{}, {}]")
|
parse("[{}, {}]")
|
||||||
)
|
)
|
||||||
|
@ -66,7 +66,7 @@ class RemoveTest {
|
||||||
"null, null"
|
"null, null"
|
||||||
})
|
})
|
||||||
void testApplyToString(String original, String expected) {
|
void testApplyToString(String original, String expected) {
|
||||||
var policy = new Remove(List.of());
|
var policy = new Remove(fieldName -> true);
|
||||||
assertThat(policy.applyToString(original)).isEqualTo(expected);
|
assertThat(policy.applyToString(original)).isEqualTo(expected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,35 +15,35 @@ import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
|
||||||
class ReplaceTest {
|
class ReplaceTest {
|
||||||
|
|
||||||
private static final List<String> TARGET_FIELDS = List.of("id", "name");
|
private static final FieldsSelector FIELDS_SELECTOR = fieldName -> List.of("id", "name").contains(fieldName);
|
||||||
private static final String REPLACEMENT_STRING = "***";
|
private static final String REPLACEMENT_STRING = "***";
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource
|
@MethodSource
|
||||||
void testApplyToJsonContainer(List<String> fields, ContainerNode<?> original, ContainerNode<?> expected) {
|
void testApplyToJsonContainer(FieldsSelector fieldsSelector, ContainerNode<?> original, ContainerNode<?> expected) {
|
||||||
var policy = new Replace(fields, REPLACEMENT_STRING);
|
var policy = new Replace(fieldsSelector, REPLACEMENT_STRING);
|
||||||
assertThat(policy.applyToJsonContainer(original)).isEqualTo(expected);
|
assertThat(policy.applyToJsonContainer(original)).isEqualTo(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Stream<Arguments> testApplyToJsonContainer() {
|
private static Stream<Arguments> testApplyToJsonContainer() {
|
||||||
return Stream.of(
|
return Stream.of(
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
TARGET_FIELDS,
|
FIELDS_SELECTOR,
|
||||||
parse("{ \"id\": 123, \"name\": { \"first\": \"James\", \"surname\": \"Bond777!\"}}"),
|
parse("{ \"id\": 123, \"name\": { \"first\": \"James\", \"surname\": \"Bond777!\"}}"),
|
||||||
parse("{ \"id\": \"***\", \"name\": { \"first\": \"***\", \"surname\": \"***\"}}")
|
parse("{ \"id\": \"***\", \"name\": { \"first\": \"***\", \"surname\": \"***\"}}")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
TARGET_FIELDS,
|
FIELDS_SELECTOR,
|
||||||
parse("[{ \"id\": 123, \"f2\": 234}, { \"name\": \"1.2\", \"f2\": 345} ]"),
|
parse("[{ \"id\": 123, \"f2\": 234}, { \"name\": \"1.2\", \"f2\": 345} ]"),
|
||||||
parse("[{ \"id\": \"***\", \"f2\": 234}, { \"name\": \"***\", \"f2\": 345} ]")
|
parse("[{ \"id\": \"***\", \"f2\": 234}, { \"name\": \"***\", \"f2\": 345} ]")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
TARGET_FIELDS,
|
FIELDS_SELECTOR,
|
||||||
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"),
|
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"Bond777!\"}}"),
|
||||||
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"***\"}}")
|
parse("{ \"outer\": { \"f1\": \"James\", \"name\": \"***\"}}")
|
||||||
),
|
),
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
List.of(),
|
(FieldsSelector) (fieldName -> true),
|
||||||
parse("{ \"outer\": { \"f1\": \"v1\", \"f2\": \"v2\", \"inner\" : {\"if1\": \"iv1\"}}}"),
|
parse("{ \"outer\": { \"f1\": \"v1\", \"f2\": \"v2\", \"inner\" : {\"if1\": \"iv1\"}}}"),
|
||||||
parse("{ \"outer\": { \"f1\": \"***\", \"f2\": \"***\", \"inner\" : {\"if1\": \"***\"}}}}")
|
parse("{ \"outer\": { \"f1\": \"***\", \"f2\": \"***\", \"inner\" : {\"if1\": \"***\"}}}}")
|
||||||
)
|
)
|
||||||
|
@ -62,7 +62,7 @@ class ReplaceTest {
|
||||||
"null, ***"
|
"null, ***"
|
||||||
})
|
})
|
||||||
void testApplyToString(String original, String expected) {
|
void testApplyToString(String original, String expected) {
|
||||||
var policy = new Replace(List.of(), REPLACEMENT_STRING);
|
var policy = new Replace(fieldName -> true, REPLACEMENT_STRING);
|
||||||
assertThat(policy.applyToString(original)).isEqualTo(expected);
|
assertThat(policy.applyToString(original)).isEqualTo(expected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
1
kafka-ui-api/src/test/resources/fileForUploadTest.txt
Normal file
1
kafka-ui-api/src/test/resources/fileForUploadTest.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
some content goes here
|
|
@ -101,9 +101,6 @@
|
||||||
<useSpringBoot3>true</useSpringBoot3>
|
<useSpringBoot3>true</useSpringBoot3>
|
||||||
<dateLibrary>java8</dateLibrary>
|
<dateLibrary>java8</dateLibrary>
|
||||||
</configOptions>
|
</configOptions>
|
||||||
<typeMappings>
|
|
||||||
<mapping>filepart=org.springframework.http.codec.multipart.FilePart</mapping>
|
|
||||||
</typeMappings>
|
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution>
|
<execution>
|
||||||
|
|
|
@ -1730,6 +1730,125 @@ paths:
|
||||||
404:
|
404:
|
||||||
description: Not found
|
description: Not found
|
||||||
|
|
||||||
|
/api/clusters/{clusterName}/acls:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Acls
|
||||||
|
summary: listKafkaAcls
|
||||||
|
operationId: listAcls
|
||||||
|
parameters:
|
||||||
|
- name: clusterName
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: resourceType
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/KafkaAclResourceType'
|
||||||
|
- name: resourceName
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: namePatternType
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/KafkaAclNamePatternType'
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/KafkaAcl'
|
||||||
|
|
||||||
|
/api/clusters/{clusterName}/acl/csv:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Acls
|
||||||
|
summary: getAclAsCsv
|
||||||
|
operationId: getAclAsCsv
|
||||||
|
parameters:
|
||||||
|
- name: clusterName
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Acls
|
||||||
|
summary: syncAclsCsv
|
||||||
|
operationId: syncAclsCsv
|
||||||
|
parameters:
|
||||||
|
- name: clusterName
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
/api/clusters/{clusterName}/acl:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Acls
|
||||||
|
summary: createAcl
|
||||||
|
operationId: createAcl
|
||||||
|
parameters:
|
||||||
|
- name: clusterName
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/KafkaAcl'
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: OK
|
||||||
|
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Acls
|
||||||
|
summary: deleteAcl
|
||||||
|
operationId: deleteAcl
|
||||||
|
parameters:
|
||||||
|
- name: clusterName
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/KafkaAcl'
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: OK
|
||||||
|
404:
|
||||||
|
description: Acl not found
|
||||||
|
|
||||||
/api/authorization:
|
/api/authorization:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
@ -1819,7 +1938,7 @@ paths:
|
||||||
properties:
|
properties:
|
||||||
file:
|
file:
|
||||||
type: string
|
type: string
|
||||||
format: filepart
|
format: binary
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: OK
|
description: OK
|
||||||
|
@ -1972,6 +2091,8 @@ components:
|
||||||
- KAFKA_CONNECT
|
- KAFKA_CONNECT
|
||||||
- KSQL_DB
|
- KSQL_DB
|
||||||
- TOPIC_DELETION
|
- TOPIC_DELETION
|
||||||
|
- KAFKA_ACL_VIEW # get ACLs listing
|
||||||
|
- KAFKA_ACL_EDIT # create & delete ACLs
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- name
|
- name
|
||||||
|
@ -3342,6 +3463,62 @@ components:
|
||||||
- SCHEMA
|
- SCHEMA
|
||||||
- CONNECT
|
- CONNECT
|
||||||
- KSQL
|
- KSQL
|
||||||
|
- ACL
|
||||||
|
|
||||||
|
KafkaAcl:
|
||||||
|
type: object
|
||||||
|
required: [resourceType, resourceName, namePatternType, principal, host, operation, permission]
|
||||||
|
properties:
|
||||||
|
resourceType:
|
||||||
|
$ref: '#/components/schemas/KafkaAclResourceType'
|
||||||
|
resourceName:
|
||||||
|
type: string # "*" if acl can be applied to any resource of given type
|
||||||
|
namePatternType:
|
||||||
|
$ref: '#/components/schemas/KafkaAclNamePatternType'
|
||||||
|
principal:
|
||||||
|
type: string
|
||||||
|
host:
|
||||||
|
type: string # "*" if acl can be applied to any resource of given type
|
||||||
|
operation:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- UNKNOWN # Unknown operation, need to update mapping code on BE
|
||||||
|
- ALL # Cluster, Topic, Group
|
||||||
|
- READ # Topic, Group
|
||||||
|
- WRITE # Topic, TransactionalId
|
||||||
|
- CREATE # Cluster, Topic
|
||||||
|
- DELETE # Topic, Group
|
||||||
|
- ALTER # Cluster, Topic,
|
||||||
|
- DESCRIBE # Cluster, Topic, Group, TransactionalId, DelegationToken
|
||||||
|
- CLUSTER_ACTION # Cluster
|
||||||
|
- DESCRIBE_CONFIGS # Cluster, Topic
|
||||||
|
- ALTER_CONFIGS # Cluster, Topic
|
||||||
|
- IDEMPOTENT_WRITE # Cluster
|
||||||
|
- CREATE_TOKENS
|
||||||
|
- DESCRIBE_TOKENS
|
||||||
|
permission:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- ALLOW
|
||||||
|
- DENY
|
||||||
|
|
||||||
|
KafkaAclResourceType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- UNKNOWN # Unknown operation, need to update mapping code on BE
|
||||||
|
- TOPIC
|
||||||
|
- GROUP
|
||||||
|
- CLUSTER
|
||||||
|
- TRANSACTIONAL_ID
|
||||||
|
- DELEGATION_TOKEN
|
||||||
|
- USER
|
||||||
|
|
||||||
|
KafkaAclNamePatternType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- MATCH
|
||||||
|
- LITERAL
|
||||||
|
- PREFIXED
|
||||||
|
|
||||||
RestartRequest:
|
RestartRequest:
|
||||||
type: object
|
type: object
|
||||||
|
@ -3632,7 +3809,9 @@ components:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
pattern:
|
fieldsNamePattern:
|
||||||
|
type: string
|
||||||
|
maskingCharsReplacement:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
<httpcomponents.version>5.2.1</httpcomponents.version>
|
<httpcomponents.version>5.2.1</httpcomponents.version>
|
||||||
<selenium.version>4.8.1</selenium.version>
|
<selenium.version>4.8.1</selenium.version>
|
||||||
<selenide.version>6.12.3</selenide.version>
|
<selenide.version>6.12.3</selenide.version>
|
||||||
<testng.version>7.7.0</testng.version>
|
<testng.version>7.7.1</testng.version>
|
||||||
<allure.version>2.21.0</allure.version>
|
<allure.version>2.21.0</allure.version>
|
||||||
<qase.io.version>3.0.4</qase.io.version>
|
<qase.io.version>3.0.4</qase.io.version>
|
||||||
<aspectj.version>1.9.9.1</aspectj.version>
|
<aspectj.version>1.9.9.1</aspectj.version>
|
||||||
|
|
|
@ -48,7 +48,8 @@ public class BrokersList extends BasePage {
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<SelenideElement> getEnabledColumnHeaders() {
|
private List<SelenideElement> getEnabledColumnHeaders() {
|
||||||
return Stream.of("Broker ID", "Segment Size", "Segment Count", "Port", "Host")
|
return Stream.of("Broker ID", "Disk usage", "Partitions skew",
|
||||||
|
"Leaders", "Leader skew", "Online partitions", "Port", "Host")
|
||||||
.map(name -> $x(String.format(columnHeaderLocator, name)))
|
.map(name -> $x(String.format(columnHeaderLocator, name)))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,11 +17,12 @@ import java.util.List;
|
||||||
public class KsqlQueryForm extends BasePage {
|
public class KsqlQueryForm extends BasePage {
|
||||||
protected SelenideElement clearBtn = $x("//div/button[text()='Clear']");
|
protected SelenideElement clearBtn = $x("//div/button[text()='Clear']");
|
||||||
protected SelenideElement executeBtn = $x("//div/button[text()='Execute']");
|
protected SelenideElement executeBtn = $x("//div/button[text()='Execute']");
|
||||||
protected SelenideElement stopQueryBtn = $x("//div/button[text()='Stop query']");
|
|
||||||
protected SelenideElement clearResultsBtn = $x("//div/button[text()='Clear results']");
|
protected SelenideElement clearResultsBtn = $x("//div/button[text()='Clear results']");
|
||||||
protected SelenideElement addStreamPropertyBtn = $x("//button[text()='Add Stream Property']");
|
protected SelenideElement addStreamPropertyBtn = $x("//button[text()='Add Stream Property']");
|
||||||
protected SelenideElement queryAreaValue = $x("//div[@class='ace_content']");
|
protected SelenideElement queryAreaValue = $x("//div[@class='ace_content']");
|
||||||
protected SelenideElement queryArea = $x("//div[@id='ksql']/textarea[@class='ace_text-input']");
|
protected SelenideElement queryArea = $x("//div[@id='ksql']/textarea[@class='ace_text-input']");
|
||||||
|
protected SelenideElement abortButton = $x("//div[@role='status']/div[text()='Abort']");
|
||||||
|
protected SelenideElement cancelledAlert = $x("//div[@role='status'][text()='Cancelled']");
|
||||||
protected ElementsCollection ksqlGridItems = $$x("//tbody//tr");
|
protected ElementsCollection ksqlGridItems = $$x("//tbody//tr");
|
||||||
protected ElementsCollection keyField = $$x("//input[@aria-label='key']");
|
protected ElementsCollection keyField = $$x("//input[@aria-label='key']");
|
||||||
protected ElementsCollection valueField = $$x("//input[@aria-label='value']");
|
protected ElementsCollection valueField = $$x("//input[@aria-label='value']");
|
||||||
|
@ -48,7 +49,7 @@ public class KsqlQueryForm extends BasePage {
|
||||||
public KsqlQueryForm clickExecuteBtn(String query) {
|
public KsqlQueryForm clickExecuteBtn(String query) {
|
||||||
clickByActions(executeBtn);
|
clickByActions(executeBtn);
|
||||||
if (query.contains("EMIT CHANGES")) {
|
if (query.contains("EMIT CHANGES")) {
|
||||||
loadingSpinner.shouldBe(Condition.visible);
|
abortButton.shouldBe(Condition.visible);
|
||||||
} else {
|
} else {
|
||||||
waitUntilSpinnerDisappear();
|
waitUntilSpinnerDisappear();
|
||||||
}
|
}
|
||||||
|
@ -56,12 +57,21 @@ public class KsqlQueryForm extends BasePage {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Step
|
@Step
|
||||||
public KsqlQueryForm clickStopQueryBtn() {
|
public boolean isAbortBtnVisible() {
|
||||||
clickByActions(stopQueryBtn);
|
return isVisible(abortButton);
|
||||||
waitUntilSpinnerDisappear();
|
}
|
||||||
|
|
||||||
|
@Step
|
||||||
|
public KsqlQueryForm clickAbortBtn() {
|
||||||
|
clickByActions(abortButton);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Step
|
||||||
|
public boolean isCancelledAlertVisible() {
|
||||||
|
return isVisible(cancelledAlert);
|
||||||
|
}
|
||||||
|
|
||||||
@Step
|
@Step
|
||||||
public KsqlQueryForm clickClearResultsBtn() {
|
public KsqlQueryForm clickClearResultsBtn() {
|
||||||
clickByActions(clearResultsBtn);
|
clickByActions(clearResultsBtn);
|
||||||
|
|
|
@ -21,80 +21,66 @@ public class SmokeBacklog extends BaseManualTest {
|
||||||
public void testCaseA() {
|
public void testCaseA() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
|
||||||
@Suite(id = KSQL_DB_SUITE_ID)
|
|
||||||
@QaseId(277)
|
|
||||||
@Test
|
|
||||||
public void testCaseB() {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
|
||||||
@Suite(id = KSQL_DB_SUITE_ID)
|
|
||||||
@QaseId(278)
|
|
||||||
@Test
|
|
||||||
public void testCaseC() {
|
|
||||||
}
|
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
@Automation(state = TO_BE_AUTOMATED)
|
||||||
@Suite(id = BROKERS_SUITE_ID)
|
@Suite(id = BROKERS_SUITE_ID)
|
||||||
@QaseId(331)
|
@QaseId(331)
|
||||||
@Test
|
@Test
|
||||||
public void testCaseD() {
|
public void testCaseB() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
@Automation(state = TO_BE_AUTOMATED)
|
||||||
@Suite(id = BROKERS_SUITE_ID)
|
@Suite(id = BROKERS_SUITE_ID)
|
||||||
@QaseId(332)
|
@QaseId(332)
|
||||||
@Test
|
@Test
|
||||||
public void testCaseE() {
|
public void testCaseC() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
@Automation(state = TO_BE_AUTOMATED)
|
||||||
@Suite(id = TOPICS_PROFILE_SUITE_ID)
|
@Suite(id = TOPICS_PROFILE_SUITE_ID)
|
||||||
@QaseId(335)
|
@QaseId(335)
|
||||||
@Test
|
@Test
|
||||||
public void testCaseF() {
|
public void testCaseD() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
@Automation(state = TO_BE_AUTOMATED)
|
||||||
@Suite(id = TOPICS_PROFILE_SUITE_ID)
|
@Suite(id = TOPICS_PROFILE_SUITE_ID)
|
||||||
@QaseId(336)
|
@QaseId(336)
|
||||||
@Test
|
@Test
|
||||||
public void testCaseG() {
|
public void testCaseE() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
@Automation(state = TO_BE_AUTOMATED)
|
||||||
@Suite(id = TOPICS_PROFILE_SUITE_ID)
|
@Suite(id = TOPICS_PROFILE_SUITE_ID)
|
||||||
@QaseId(343)
|
@QaseId(343)
|
||||||
@Test
|
@Test
|
||||||
public void testCaseH() {
|
public void testCaseF() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
@Automation(state = TO_BE_AUTOMATED)
|
||||||
@Suite(id = KSQL_DB_SUITE_ID)
|
@Suite(id = KSQL_DB_SUITE_ID)
|
||||||
@QaseId(344)
|
@QaseId(344)
|
||||||
@Test
|
@Test
|
||||||
public void testCaseI() {
|
public void testCaseG() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
@Automation(state = TO_BE_AUTOMATED)
|
||||||
@Suite(id = SCHEMAS_SUITE_ID)
|
@Suite(id = SCHEMAS_SUITE_ID)
|
||||||
@QaseId(345)
|
@QaseId(345)
|
||||||
@Test
|
@Test
|
||||||
public void testCaseJ() {
|
public void testCaseH() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
@Automation(state = TO_BE_AUTOMATED)
|
||||||
@Suite(id = SCHEMAS_SUITE_ID)
|
@Suite(id = SCHEMAS_SUITE_ID)
|
||||||
@QaseId(346)
|
@QaseId(346)
|
||||||
@Test
|
@Test
|
||||||
public void testCaseK() {
|
public void testCaseI() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Automation(state = TO_BE_AUTOMATED)
|
@Automation(state = TO_BE_AUTOMATED)
|
||||||
@Suite(id = TOPICS_PROFILE_SUITE_ID)
|
@Suite(id = TOPICS_PROFILE_SUITE_ID)
|
||||||
@QaseId(347)
|
@QaseId(347)
|
||||||
@Test
|
@Test
|
||||||
public void testCaseL() {
|
public void testCaseJ() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package com.provectus.kafka.ui.smokesuite.ksqldb;
|
package com.provectus.kafka.ui.smokesuite.ksqldb;
|
||||||
|
|
||||||
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlMenuTabs.STREAMS;
|
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlMenuTabs.STREAMS;
|
||||||
|
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SELECT_ALL_FROM;
|
||||||
|
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SHOW_STREAMS;
|
||||||
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SHOW_TABLES;
|
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SHOW_TABLES;
|
||||||
import static com.provectus.kafka.ui.pages.panels.enums.MenuItem.KSQL_DB;
|
import static com.provectus.kafka.ui.pages.panels.enums.MenuItem.KSQL_DB;
|
||||||
import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
|
import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
|
||||||
|
@ -80,8 +82,19 @@ public class KsqlDbTest extends BaseTest {
|
||||||
softly.assertAll();
|
softly.assertAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@QaseId(86)
|
@QaseId(278)
|
||||||
@Test(priority = 4)
|
@Test(priority = 4)
|
||||||
|
public void checkShowStreamsRequestExecution() {
|
||||||
|
navigateToKsqlDbAndExecuteRequest(SHOW_STREAMS.getQuery());
|
||||||
|
SoftAssert softly = new SoftAssert();
|
||||||
|
softly.assertTrue(ksqlQueryForm.areResultsVisible(), "areResultsVisible()");
|
||||||
|
softly.assertTrue(ksqlQueryForm.getItemByName(DEFAULT_STREAM.getName()).isVisible(),
|
||||||
|
String.format("getItemByName(%s)", FIRST_TABLE.getName()));
|
||||||
|
softly.assertAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@QaseId(86)
|
||||||
|
@Test(priority = 5)
|
||||||
public void clearResultsForExecutedRequest() {
|
public void clearResultsForExecutedRequest() {
|
||||||
navigateToKsqlDbAndExecuteRequest(SHOW_TABLES.getQuery());
|
navigateToKsqlDbAndExecuteRequest(SHOW_TABLES.getQuery());
|
||||||
SoftAssert softly = new SoftAssert();
|
SoftAssert softly = new SoftAssert();
|
||||||
|
@ -93,6 +106,16 @@ public class KsqlDbTest extends BaseTest {
|
||||||
softly.assertAll();
|
softly.assertAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@QaseId(277)
|
||||||
|
@Test(priority = 6)
|
||||||
|
public void stopQueryFunctionalCheck() {
|
||||||
|
navigateToKsqlDbAndExecuteRequest(String.format(SELECT_ALL_FROM.getQuery(), FIRST_TABLE.getName()));
|
||||||
|
Assert.assertTrue(ksqlQueryForm.isAbortBtnVisible(), "isAbortBtnVisible()");
|
||||||
|
ksqlQueryForm
|
||||||
|
.clickAbortBtn();
|
||||||
|
Assert.assertTrue(ksqlQueryForm.isCancelledAlertVisible(), "isCancelledAlertVisible()");
|
||||||
|
}
|
||||||
|
|
||||||
@AfterClass(alwaysRun = true)
|
@AfterClass(alwaysRun = true)
|
||||||
public void afterClass() {
|
public void afterClass() {
|
||||||
TOPIC_NAMES_LIST.forEach(topicName -> apiService.deleteTopic(topicName));
|
TOPIC_NAMES_LIST.forEach(topicName -> apiService.deleteTopic(topicName));
|
||||||
|
|
|
@ -11,7 +11,9 @@ import CheckMarkRoundIcon from 'components/common/Icons/CheckMarkRoundIcon';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { clusterBrokerPath } from 'lib/paths';
|
import { clusterBrokerPath } from 'lib/paths';
|
||||||
import Tooltip from 'components/common/Tooltip/Tooltip';
|
import Tooltip from 'components/common/Tooltip/Tooltip';
|
||||||
|
import ColoredCell from 'components/common/NewTable/ColoredCell';
|
||||||
|
|
||||||
|
import SkewHeader from './SkewHeader/SkewHeader';
|
||||||
import * as S from './BrokersList.styled';
|
import * as S from './BrokersList.styled';
|
||||||
|
|
||||||
const NA = 'N/A';
|
const NA = 'N/A';
|
||||||
|
@ -57,11 +59,15 @@ const BrokersList: React.FC = () => {
|
||||||
count: segmentCount || NA,
|
count: segmentCount || NA,
|
||||||
port: broker?.port,
|
port: broker?.port,
|
||||||
host: broker?.host,
|
host: broker?.host,
|
||||||
|
partitionsLeader: broker?.partitionsLeader,
|
||||||
|
partitionsSkew: broker?.partitionsSkew,
|
||||||
|
leadersSkew: broker?.leadersSkew,
|
||||||
|
inSyncPartitions: broker?.inSyncPartitions,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [diskUsage, brokers]);
|
}, [diskUsage, brokers]);
|
||||||
|
|
||||||
const columns = React.useMemo<ColumnDef<typeof rows>[]>(
|
const columns = React.useMemo<ColumnDef<(typeof rows)[number]>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
header: 'Broker ID',
|
header: 'Broker ID',
|
||||||
|
@ -84,7 +90,7 @@ const BrokersList: React.FC = () => {
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Segment Size',
|
header: 'Disk usage',
|
||||||
accessorKey: 'size',
|
accessorKey: 'size',
|
||||||
// eslint-disable-next-line react/no-unstable-nested-components
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
cell: ({ getValue, table, cell, column, renderValue, row }) =>
|
cell: ({ getValue, table, cell, column, renderValue, row }) =>
|
||||||
|
@ -98,10 +104,56 @@ const BrokersList: React.FC = () => {
|
||||||
cell={cell}
|
cell={cell}
|
||||||
getValue={getValue}
|
getValue={getValue}
|
||||||
renderValue={renderValue}
|
renderValue={renderValue}
|
||||||
|
renderSegments
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ header: 'Segment Count', accessorKey: 'count' },
|
{
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
|
header: () => <SkewHeader />,
|
||||||
|
accessorKey: 'partitionsSkew',
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue<number>();
|
||||||
|
return (
|
||||||
|
<ColoredCell
|
||||||
|
value={value ? `${value.toFixed(2)}%` : '-'}
|
||||||
|
warn={value >= 10 && value < 20}
|
||||||
|
attention={value >= 20}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ header: 'Leaders', accessorKey: 'partitionsLeader' },
|
||||||
|
{
|
||||||
|
header: 'Leader skew',
|
||||||
|
accessorKey: 'leadersSkew',
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue<number>();
|
||||||
|
return (
|
||||||
|
<ColoredCell
|
||||||
|
value={value ? `${value.toFixed(2)}%` : '-'}
|
||||||
|
warn={value >= 10 && value < 20}
|
||||||
|
attention={value >= 20}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Online partitions',
|
||||||
|
accessorKey: 'inSyncPartitions',
|
||||||
|
// eslint-disable-next-line react/no-unstable-nested-components
|
||||||
|
cell: ({ getValue, row }) => {
|
||||||
|
const value = getValue<number>();
|
||||||
|
return (
|
||||||
|
<ColoredCell
|
||||||
|
value={value}
|
||||||
|
attention={value !== row.original.count}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ header: 'Port', accessorKey: 'port' },
|
{ header: 'Port', accessorKey: 'port' },
|
||||||
{
|
{
|
||||||
header: 'Host',
|
header: 'Host',
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { MessageTooltip } from 'components/common/Tooltip/Tooltip.styled';
|
||||||
|
|
||||||
|
export const CellWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
${MessageTooltip} {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
`;
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Tooltip from 'components/common/Tooltip/Tooltip';
|
||||||
|
import InfoIcon from 'components/common/Icons/InfoIcon';
|
||||||
|
|
||||||
|
import * as S from './SkewHeader.styled';
|
||||||
|
|
||||||
|
const SkewHeader: React.FC = () => (
|
||||||
|
<S.CellWrapper>
|
||||||
|
Partitions skew
|
||||||
|
<Tooltip
|
||||||
|
value={<InfoIcon />}
|
||||||
|
content="The divergence from the average brokers' value"
|
||||||
|
/>
|
||||||
|
</S.CellWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SkewHeader;
|
|
@ -37,7 +37,7 @@ const Config: React.FC = () => {
|
||||||
formState: { isDirty, isSubmitting, isValid, errors },
|
formState: { isDirty, isSubmitting, isValid, errors },
|
||||||
setValue,
|
setValue,
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
mode: 'onTouched',
|
mode: 'onChange',
|
||||||
resolver: yupResolver(validationSchema),
|
resolver: yupResolver(validationSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
config: JSON.stringify(config, null, '\t'),
|
config: JSON.stringify(config, null, '\t'),
|
||||||
|
|
|
@ -1,17 +1,31 @@
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Cluster } from 'generated-sources';
|
import { Cluster, ResourceType } from 'generated-sources';
|
||||||
import { CellContext } from '@tanstack/react-table';
|
import { CellContext } from '@tanstack/react-table';
|
||||||
import { Button } from 'components/common/Button/Button';
|
|
||||||
import { clusterConfigPath } from 'lib/paths';
|
import { clusterConfigPath } from 'lib/paths';
|
||||||
|
import { useGetUserInfo } from 'lib/hooks/api/roles';
|
||||||
|
import { ActionCanButton } from 'components/common/ActionComponent';
|
||||||
|
|
||||||
type Props = CellContext<Cluster, unknown>;
|
type Props = CellContext<Cluster, unknown>;
|
||||||
|
|
||||||
const ClusterTableActionsCell: React.FC<Props> = ({ row }) => {
|
const ClusterTableActionsCell: React.FC<Props> = ({ row }) => {
|
||||||
const { name } = row.original;
|
const { name } = row.original;
|
||||||
|
const { data } = useGetUserInfo();
|
||||||
|
|
||||||
|
const isApplicationConfig = useMemo(() => {
|
||||||
|
return !!data?.userInfo?.permissions.some(
|
||||||
|
(permission) => permission.resource === ResourceType.APPLICATIONCONFIG
|
||||||
|
);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button buttonType="secondary" buttonSize="S" to={clusterConfigPath(name)}>
|
<ActionCanButton
|
||||||
|
buttonType="secondary"
|
||||||
|
buttonSize="S"
|
||||||
|
to={clusterConfigPath(name)}
|
||||||
|
canDoAction={isApplicationConfig}
|
||||||
|
>
|
||||||
Configure
|
Configure
|
||||||
</Button>
|
</ActionCanButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,25 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
import PageHeading from 'components/common/PageHeading/PageHeading';
|
import PageHeading from 'components/common/PageHeading/PageHeading';
|
||||||
import * as Metrics from 'components/common/Metrics';
|
import * as Metrics from 'components/common/Metrics';
|
||||||
import { Tag } from 'components/common/Tag/Tag.styled';
|
import { Tag } from 'components/common/Tag/Tag.styled';
|
||||||
import Switch from 'components/common/Switch/Switch';
|
import Switch from 'components/common/Switch/Switch';
|
||||||
import { useClusters } from 'lib/hooks/api/clusters';
|
import { useClusters } from 'lib/hooks/api/clusters';
|
||||||
import { Cluster, ServerStatus } from 'generated-sources';
|
import { Cluster, ResourceType, ServerStatus } from 'generated-sources';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import Table, { SizeCell } from 'components/common/NewTable';
|
import Table, { SizeCell } from 'components/common/NewTable';
|
||||||
import useBoolean from 'lib/hooks/useBoolean';
|
import useBoolean from 'lib/hooks/useBoolean';
|
||||||
import { Button } from 'components/common/Button/Button';
|
|
||||||
import { clusterNewConfigPath } from 'lib/paths';
|
import { clusterNewConfigPath } from 'lib/paths';
|
||||||
import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext';
|
import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ActionCanButton } from 'components/common/ActionComponent';
|
||||||
|
import { useGetUserInfo } from 'lib/hooks/api/roles';
|
||||||
|
|
||||||
import * as S from './Dashboard.styled';
|
import * as S from './Dashboard.styled';
|
||||||
import ClusterName from './ClusterName';
|
import ClusterName from './ClusterName';
|
||||||
import ClusterTableActionsCell from './ClusterTableActionsCell';
|
import ClusterTableActionsCell from './ClusterTableActionsCell';
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
|
const { data } = useGetUserInfo();
|
||||||
const clusters = useClusters();
|
const clusters = useClusters();
|
||||||
const { value: showOfflineOnly, toggle } = useBoolean(false);
|
const { value: showOfflineOnly, toggle } = useBoolean(false);
|
||||||
const appInfo = React.useContext(GlobalSettingsContext);
|
const appInfo = React.useContext(GlobalSettingsContext);
|
||||||
|
@ -62,6 +64,11 @@ const Dashboard: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [clusters, appInfo.hasDynamicConfig]);
|
}, [clusters, appInfo.hasDynamicConfig]);
|
||||||
|
|
||||||
|
const isApplicationConfig = useMemo(() => {
|
||||||
|
return !!data?.userInfo?.permissions.some(
|
||||||
|
(permission) => permission.resource === ResourceType.APPLICATIONCONFIG
|
||||||
|
);
|
||||||
|
}, [data]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeading text="Dashboard" />
|
<PageHeading text="Dashboard" />
|
||||||
|
@ -87,9 +94,14 @@ const Dashboard: React.FC = () => {
|
||||||
<label>Only offline clusters</label>
|
<label>Only offline clusters</label>
|
||||||
</div>
|
</div>
|
||||||
{appInfo.hasDynamicConfig && (
|
{appInfo.hasDynamicConfig && (
|
||||||
<Button buttonType="primary" buttonSize="M" to={clusterNewConfigPath}>
|
<ActionCanButton
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="M"
|
||||||
|
to={clusterNewConfigPath}
|
||||||
|
canDoAction={isApplicationConfig}
|
||||||
|
>
|
||||||
Configure new cluster
|
Configure new cluster
|
||||||
</Button>
|
</ActionCanButton>
|
||||||
)}
|
)}
|
||||||
</S.Toolbar>
|
</S.Toolbar>
|
||||||
<Table
|
<Table
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { Button } from 'components/common/Button/Button';
|
||||||
|
|
||||||
export const DiffWrapper = styled.div`
|
export const DiffWrapper = styled.div`
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
@ -81,3 +82,6 @@ export const DiffTile = styled.div`
|
||||||
export const DiffVersionsSelect = styled.div`
|
export const DiffVersionsSelect = styled.div`
|
||||||
width: 0.625em;
|
width: 0.625em;
|
||||||
`;
|
`;
|
||||||
|
export const BackButton = styled(Button)`
|
||||||
|
margin: 10px 9px;
|
||||||
|
`;
|
||||||
|
|
|
@ -20,6 +20,7 @@ import useAppParams from 'lib/hooks/useAppParams';
|
||||||
import PageHeading from 'components/common/PageHeading/PageHeading';
|
import PageHeading from 'components/common/PageHeading/PageHeading';
|
||||||
|
|
||||||
import * as S from './Diff.styled';
|
import * as S from './Diff.styled';
|
||||||
|
import { BackButton } from './Diff.styled';
|
||||||
|
|
||||||
export interface DiffProps {
|
export interface DiffProps {
|
||||||
versions: SchemaSubject[];
|
versions: SchemaSubject[];
|
||||||
|
@ -77,6 +78,13 @@ const Diff: React.FC<DiffProps> = ({ versions, areVersionsFetched }) => {
|
||||||
backText="Schema Registry"
|
backText="Schema Registry"
|
||||||
backTo={clusterSchemasPath(clusterName)}
|
backTo={clusterSchemasPath(clusterName)}
|
||||||
/>
|
/>
|
||||||
|
<BackButton
|
||||||
|
buttonType="secondary"
|
||||||
|
buttonSize="S"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</BackButton>
|
||||||
<S.Section>
|
<S.Section>
|
||||||
{areVersionsFetched ? (
|
{areVersionsFetched ? (
|
||||||
<S.DiffBox>
|
<S.DiffBox>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import Diff, { DiffProps } from 'components/Schemas/Diff/Diff';
|
||||||
import { render, WithRoute } from 'lib/testHelpers';
|
import { render, WithRoute } from 'lib/testHelpers';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { clusterSchemaComparePath } from 'lib/paths';
|
import { clusterSchemaComparePath } from 'lib/paths';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
import { versions } from './fixtures';
|
import { versions } from './fixtures';
|
||||||
|
|
||||||
|
@ -142,4 +143,24 @@ describe('Diff', () => {
|
||||||
expect(select).toHaveTextContent(versions[0].version);
|
expect(select).toHaveTextContent(versions[0].version);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Back button', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setupComponent({
|
||||||
|
areVersionsFetched: true,
|
||||||
|
versions,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('back button is appear', () => {
|
||||||
|
const backButton = screen.getAllByRole('button', { name: 'Back' });
|
||||||
|
expect(backButton[0]).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('click on back button', () => {
|
||||||
|
const backButton = screen.getAllByRole('button', { name: 'Back' });
|
||||||
|
userEvent.click(backButton[0]);
|
||||||
|
expect(screen.queryByRole('Back')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -55,7 +55,7 @@ const Form: React.FC = () => {
|
||||||
yup.object().shape({
|
yup.object().shape({
|
||||||
newSchema:
|
newSchema:
|
||||||
schema?.schemaType === SchemaType.PROTOBUF
|
schema?.schemaType === SchemaType.PROTOBUF
|
||||||
? yup.string().required().isEnum('Schema syntax is not valid')
|
? yup.string().required()
|
||||||
: yup.string().required().isJsonObject('Schema syntax is not valid'),
|
: yup.string().required().isJsonObject('Schema syntax is not valid'),
|
||||||
});
|
});
|
||||||
const methods = useForm<NewSchemaSubjectRaw>({
|
const methods = useForm<NewSchemaSubjectRaw>({
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { formatTimestamp } from 'lib/dateTimeHelpers';
|
||||||
import { JSONPath } from 'jsonpath-plus';
|
import { JSONPath } from 'jsonpath-plus';
|
||||||
import Ellipsis from 'components/common/Ellipsis/Ellipsis';
|
import Ellipsis from 'components/common/Ellipsis/Ellipsis';
|
||||||
import WarningRedIcon from 'components/common/Icons/WarningRedIcon';
|
import WarningRedIcon from 'components/common/Icons/WarningRedIcon';
|
||||||
|
import Tooltip from 'components/common/Tooltip/Tooltip';
|
||||||
|
|
||||||
import MessageContent from './MessageContent/MessageContent';
|
import MessageContent from './MessageContent/MessageContent';
|
||||||
import * as S from './MessageContent/MessageContent.styled';
|
import * as S from './MessageContent/MessageContent.styled';
|
||||||
|
@ -110,14 +111,26 @@ const Message: React.FC<Props> = ({
|
||||||
</td>
|
</td>
|
||||||
<S.DataCell title={key}>
|
<S.DataCell title={key}>
|
||||||
<Ellipsis text={renderFilteredJson(key, keyFilters)}>
|
<Ellipsis text={renderFilteredJson(key, keyFilters)}>
|
||||||
{keySerde === 'Fallback' && <WarningRedIcon />}
|
{keySerde === 'Fallback' && (
|
||||||
|
<Tooltip
|
||||||
|
value={<WarningRedIcon />}
|
||||||
|
content="Fallback serde was used"
|
||||||
|
placement="left"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Ellipsis>
|
</Ellipsis>
|
||||||
</S.DataCell>
|
</S.DataCell>
|
||||||
<S.DataCell title={content}>
|
<S.DataCell title={content}>
|
||||||
<S.Metadata>
|
<S.Metadata>
|
||||||
<S.MetadataValue>
|
<S.MetadataValue>
|
||||||
<Ellipsis text={renderFilteredJson(content, contentFilters)}>
|
<Ellipsis text={renderFilteredJson(content, contentFilters)}>
|
||||||
{valueSerde === 'Fallback' && <WarningRedIcon />}
|
{valueSerde === 'Fallback' && (
|
||||||
|
<Tooltip
|
||||||
|
value={<WarningRedIcon />}
|
||||||
|
content="Fallback serde was used"
|
||||||
|
placement="left"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Ellipsis>
|
</Ellipsis>
|
||||||
</S.MetadataValue>
|
</S.MetadataValue>
|
||||||
</S.Metadata>
|
</S.Metadata>
|
||||||
|
|
|
@ -210,6 +210,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
|
||||||
name={name}
|
name={name}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={value}
|
value={value}
|
||||||
|
height="40px"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -225,6 +226,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
|
||||||
name={name}
|
name={name}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={value}
|
value={value}
|
||||||
|
height="280px"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -242,7 +244,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
|
||||||
defaultValue="{}"
|
defaultValue="{}"
|
||||||
name={name}
|
name={name}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
height="200px"
|
height="40px"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -29,6 +29,16 @@ export const Wrapper = styled.div`
|
||||||
width: 16px;
|
width: 16px;
|
||||||
fill: ${({ theme }) => theme.input.icon.color};
|
fill: ${({ theme }) => theme.input.icon.color};
|
||||||
}
|
}
|
||||||
|
svg:last-child {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
line-height: 0;
|
||||||
|
z-index: 1;
|
||||||
|
left: unset;
|
||||||
|
right: 12px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Input = styled.input<InputProps>(
|
export const Input = styled.input<InputProps>(
|
||||||
|
|
|
@ -16,6 +16,7 @@ export interface InputProps
|
||||||
withError?: boolean;
|
withError?: boolean;
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
hint?: React.ReactNode;
|
hint?: React.ReactNode;
|
||||||
|
clearIcon?: React.ReactNode;
|
||||||
|
|
||||||
// Some may only accept integer, like `Number of Partitions`
|
// Some may only accept integer, like `Number of Partitions`
|
||||||
// some may accept decimal
|
// some may accept decimal
|
||||||
|
@ -99,19 +100,22 @@ function pasteNumberCheck(
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input: React.FC<InputProps> = ({
|
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||||
name,
|
const {
|
||||||
hookFormOptions,
|
name,
|
||||||
search,
|
hookFormOptions,
|
||||||
inputSize = 'L',
|
search,
|
||||||
type,
|
inputSize = 'L',
|
||||||
positiveOnly,
|
type,
|
||||||
integerOnly,
|
positiveOnly,
|
||||||
withError = false,
|
integerOnly,
|
||||||
label,
|
withError = false,
|
||||||
hint,
|
label,
|
||||||
...rest
|
hint,
|
||||||
}) => {
|
clearIcon,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
const methods = useFormContext();
|
const methods = useFormContext();
|
||||||
|
|
||||||
const fieldId = React.useId();
|
const fieldId = React.useId();
|
||||||
|
@ -168,7 +172,6 @@ const Input: React.FC<InputProps> = ({
|
||||||
// if the field is a part of react-hook-form form
|
// if the field is a part of react-hook-form form
|
||||||
inputOptions = { ...rest, ...methods.register(name, hookFormOptions) };
|
inputOptions = { ...rest, ...methods.register(name, hookFormOptions) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{label && <InputLabel htmlFor={rest.id || fieldId}>{label}</InputLabel>}
|
{label && <InputLabel htmlFor={rest.id || fieldId}>{label}</InputLabel>}
|
||||||
|
@ -181,8 +184,11 @@ const Input: React.FC<InputProps> = ({
|
||||||
type={type}
|
type={type}
|
||||||
onKeyPress={keyPressEventHandler}
|
onKeyPress={keyPressEventHandler}
|
||||||
onPaste={pasteEventHandler}
|
onPaste={pasteEventHandler}
|
||||||
|
ref={ref}
|
||||||
{...inputOptions}
|
{...inputOptions}
|
||||||
/>
|
/>
|
||||||
|
{clearIcon}
|
||||||
|
|
||||||
{withError && isHookFormField && (
|
{withError && isHookFormField && (
|
||||||
<S.FormError>
|
<S.FormError>
|
||||||
<ErrorMessage name={name} />
|
<ErrorMessage name={name} />
|
||||||
|
@ -192,6 +198,6 @@ const Input: React.FC<InputProps> = ({
|
||||||
</S.Wrapper>
|
</S.Wrapper>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default Input;
|
export default Input;
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
interface CellProps {
|
||||||
|
isWarning?: boolean;
|
||||||
|
isAttention?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColoredCellProps {
|
||||||
|
value: number | string;
|
||||||
|
warn?: boolean;
|
||||||
|
attention?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cell = styled.div<CellProps>`
|
||||||
|
color: ${(props) => {
|
||||||
|
if (props.isAttention) {
|
||||||
|
return props.theme.table.colored.color.attention;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.isWarning) {
|
||||||
|
return props.theme.table.colored.color.warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'inherit';
|
||||||
|
}};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ColoredCell: React.FC<ColoredCellProps> = ({
|
||||||
|
value,
|
||||||
|
warn,
|
||||||
|
attention,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Cell isWarning={warn} isAttention={attention}>
|
||||||
|
{value}
|
||||||
|
</Cell>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColoredCell;
|
|
@ -3,8 +3,15 @@ import { CellContext } from '@tanstack/react-table';
|
||||||
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
|
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const SizeCell: React.FC<CellContext<any, unknown>> = ({ getValue }) => (
|
type AsAny = any;
|
||||||
<BytesFormatted value={getValue<string | number>()} />
|
|
||||||
|
const SizeCell: React.FC<
|
||||||
|
CellContext<AsAny, unknown> & { renderSegments?: boolean }
|
||||||
|
> = ({ getValue, row, renderSegments = false }) => (
|
||||||
|
<>
|
||||||
|
<BytesFormatted value={getValue<string | number>()} />
|
||||||
|
{renderSegments ? `, ${row?.original.count} segment(s)` : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default SizeCell;
|
export default SizeCell;
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import Input from 'components/common/Input/Input';
|
import Input from 'components/common/Input/Input';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import CloseIcon from 'components/common/Icons/CloseIcon';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
interface SearchProps {
|
interface SearchProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -10,6 +12,16 @@ interface SearchProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IconButtonWrapper = styled.span.attrs(() => ({
|
||||||
|
role: 'button',
|
||||||
|
tabIndex: '0',
|
||||||
|
}))`
|
||||||
|
height: 16px !important;
|
||||||
|
display: inline-block;
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`;
|
||||||
const Search: React.FC<SearchProps> = ({
|
const Search: React.FC<SearchProps> = ({
|
||||||
placeholder = 'Search',
|
placeholder = 'Search',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
@ -17,7 +29,11 @@ const Search: React.FC<SearchProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
const handleChange = useDebouncedCallback((e) => {
|
const handleChange = useDebouncedCallback((e) => {
|
||||||
|
if (ref.current != null) {
|
||||||
|
ref.current.value = e.target.value;
|
||||||
|
}
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(e.target.value);
|
onChange(e.target.value);
|
||||||
} else {
|
} else {
|
||||||
|
@ -28,6 +44,15 @@ const Search: React.FC<SearchProps> = ({
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
const clearSearchValue = () => {
|
||||||
|
if (searchParams.get('q')) {
|
||||||
|
searchParams.set('q', '');
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}
|
||||||
|
if (ref.current != null) {
|
||||||
|
ref.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
@ -37,7 +62,13 @@ const Search: React.FC<SearchProps> = ({
|
||||||
defaultValue={value || searchParams.get('q') || ''}
|
defaultValue={value || searchParams.get('q') || ''}
|
||||||
inputSize="M"
|
inputSize="M"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
ref={ref}
|
||||||
search
|
search
|
||||||
|
clearIcon={
|
||||||
|
<IconButtonWrapper onClick={clearSearchValue}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButtonWrapper>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -41,4 +41,24 @@ describe('Search', () => {
|
||||||
render(<Search />);
|
render(<Search />);
|
||||||
expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument();
|
expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Clear button is visible', () => {
|
||||||
|
render(<Search placeholder={placeholder} />);
|
||||||
|
|
||||||
|
const clearButton = screen.getByRole('button');
|
||||||
|
expect(clearButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Clear button should clear text from input', async () => {
|
||||||
|
render(<Search placeholder={placeholder} />);
|
||||||
|
|
||||||
|
const searchField = screen.getAllByRole('textbox')[0];
|
||||||
|
await userEvent.type(searchField, 'some text');
|
||||||
|
expect(searchField).toHaveValue('some text');
|
||||||
|
|
||||||
|
const clearButton = screen.getByRole('button');
|
||||||
|
await userEvent.click(clearButton);
|
||||||
|
|
||||||
|
expect(searchField).toHaveValue('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,19 +1,5 @@
|
||||||
import { isValidEnum, isValidJsonObject } from 'lib/yupExtended';
|
import { isValidJsonObject } from 'lib/yupExtended';
|
||||||
|
|
||||||
const invalidEnum = `
|
|
||||||
ennum SchemType {
|
|
||||||
AVRO = 0;
|
|
||||||
JSON = 1;
|
|
||||||
PROTOBUF = 3;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const validEnum = `
|
|
||||||
enum SchemType {
|
|
||||||
AVRO = 0;
|
|
||||||
JSON = 1;
|
|
||||||
PROTOBUF = 3;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
describe('yup extended', () => {
|
describe('yup extended', () => {
|
||||||
describe('isValidJsonObject', () => {
|
describe('isValidJsonObject', () => {
|
||||||
it('returns false for no value', () => {
|
it('returns false for no value', () => {
|
||||||
|
@ -35,21 +21,4 @@ describe('yup extended', () => {
|
||||||
expect(isValidJsonObject('{ "foo": "bar" }')).toBeTruthy();
|
expect(isValidJsonObject('{ "foo": "bar" }')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isValidEnum', () => {
|
|
||||||
it('returns false for invalid enum', () => {
|
|
||||||
expect(isValidEnum(invalidEnum)).toBeFalsy();
|
|
||||||
});
|
|
||||||
it('returns false for no value', () => {
|
|
||||||
expect(isValidEnum()).toBeFalsy();
|
|
||||||
});
|
|
||||||
it('returns true should trim value', () => {
|
|
||||||
expect(
|
|
||||||
isValidEnum(` enum SchemType {AVRO = 0; PROTOBUF = 3;} `)
|
|
||||||
).toBeTruthy();
|
|
||||||
});
|
|
||||||
it('returns true for valid enum', () => {
|
|
||||||
expect(isValidEnum(validEnum)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
export const formatTimestamp = (
|
export const formatTimestamp = (
|
||||||
timestamp?: number | string | Date,
|
timestamp?: number | string | Date,
|
||||||
format: Intl.DateTimeFormatOptions = { hour12: false }
|
format: Intl.DateTimeFormatOptions = { hourCycle: 'h23' }
|
||||||
): string => {
|
): string => {
|
||||||
if (!timestamp) {
|
if (!timestamp) {
|
||||||
return '';
|
return '';
|
||||||
|
@ -8,7 +8,6 @@ export const formatTimestamp = (
|
||||||
|
|
||||||
// empty array gets the default one from the browser
|
// empty array gets the default one from the browser
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
// invalid date
|
// invalid date
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
return '';
|
return '';
|
||||||
|
|
|
@ -10,7 +10,6 @@ declare module 'yup' {
|
||||||
TFlags extends yup.Flags = ''
|
TFlags extends yup.Flags = ''
|
||||||
> extends yup.Schema<TType, TContext, TDefault, TFlags> {
|
> extends yup.Schema<TType, TContext, TDefault, TFlags> {
|
||||||
isJsonObject(message?: string): StringSchema<TType, TContext>;
|
isJsonObject(message?: string): StringSchema<TType, TContext>;
|
||||||
isEnum(message?: string): StringSchema<TType, TContext>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,32 +39,6 @@ const isJsonObject = (message?: string) => {
|
||||||
isValidJsonObject
|
isValidJsonObject
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isValidEnum = (value?: string) => {
|
|
||||||
try {
|
|
||||||
if (!value) return false;
|
|
||||||
const trimmedValue = value.trim();
|
|
||||||
if (
|
|
||||||
trimmedValue.indexOf('enum') === 0 &&
|
|
||||||
trimmedValue.lastIndexOf('}') === trimmedValue.length - 1
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isEnum = (message?: string) => {
|
|
||||||
return yup.string().test(
|
|
||||||
'isEnum',
|
|
||||||
// eslint-disable-next-line no-template-curly-in-string
|
|
||||||
message || '${path} is not Enum object',
|
|
||||||
isValidEnum
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* due to yup rerunning all the object validiation during any render,
|
* due to yup rerunning all the object validiation during any render,
|
||||||
* it makes sense to cache the async results
|
* it makes sense to cache the async results
|
||||||
|
@ -88,7 +61,6 @@ export function cacheTest(
|
||||||
}
|
}
|
||||||
|
|
||||||
yup.addMethod(yup.StringSchema, 'isJsonObject', isJsonObject);
|
yup.addMethod(yup.StringSchema, 'isJsonObject', isJsonObject);
|
||||||
yup.addMethod(yup.StringSchema, 'isEnum', isEnum);
|
|
||||||
|
|
||||||
export const topicFormValidationSchema = yup.object().shape({
|
export const topicFormValidationSchema = yup.object().shape({
|
||||||
name: yup
|
name: yup
|
||||||
|
|
|
@ -544,6 +544,12 @@ export const theme = {
|
||||||
active: Colors.neutral[90],
|
active: Colors.neutral[90],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
colored: {
|
||||||
|
color: {
|
||||||
|
attention: Colors.red[50],
|
||||||
|
warning: Colors.yellow[20],
|
||||||
|
},
|
||||||
|
},
|
||||||
expander: {
|
expander: {
|
||||||
normal: Colors.brand[30],
|
normal: Colors.brand[30],
|
||||||
hover: Colors.brand[40],
|
hover: Colors.brand[40],
|
||||||
|
@ -942,6 +948,12 @@ export const darkTheme: ThemeType = {
|
||||||
active: Colors.neutral[0],
|
active: Colors.neutral[0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
colored: {
|
||||||
|
color: {
|
||||||
|
attention: Colors.red[50],
|
||||||
|
warning: Colors.yellow[20],
|
||||||
|
},
|
||||||
|
},
|
||||||
expander: {
|
expander: {
|
||||||
normal: Colors.brand[30],
|
normal: Colors.brand[30],
|
||||||
hover: Colors.brand[40],
|
hover: Colors.brand[40],
|
||||||
|
|
Loading…
Add table
Reference in a new issue