Merge remote-tracking branch 'origin/master' into issues/2844
# Conflicts: # kafka-ui-api/src/main/java/com/provectus/kafka/ui/config/auth/OAuthSecurityConfig.java # kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/AccessControlService.java # kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/rbac/extractor/LdapAuthorityExtractor.java
This commit is contained in:
commit
29bd456a0b
24 changed files with 341 additions and 312 deletions
|
@ -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 var provider = getProviderByProviderId(providerId);
|
final var provider = getProviderByProviderId(providerId);
|
||||||
Optional<ProviderAuthorityExtractor> extractor = acs.getExtractors()
|
Optional<ProviderAuthorityExtractor> extractor = acs.getOauthExtractors()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(e -> e.isApplicable(provider.getProvider(), provider.getCustomParams()))
|
.filter(e -> e.isApplicable(provider.getProvider(), provider.getCustomParams()))
|
||||||
.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 {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -123,11 +123,11 @@ public class ConsumerRecordDeserializer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Long getKeySize(ConsumerRecord<Bytes, Bytes> consumerRecord) {
|
private static Long getKeySize(ConsumerRecord<Bytes, Bytes> consumerRecord) {
|
||||||
return consumerRecord.key() != null ? (long) consumerRecord.key().get().length : null;
|
return consumerRecord.key() != null ? (long) consumerRecord.serializedKeySize() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Long getValueSize(ConsumerRecord<Bytes, Bytes> consumerRecord) {
|
private static Long getValueSize(ConsumerRecord<Bytes, Bytes> consumerRecord) {
|
||||||
return consumerRecord.value() != null ? (long) consumerRecord.value().get().length : null;
|
return consumerRecord.value() != null ? (long) consumerRecord.serializedValueSize() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int headerSize(Header header) {
|
private static int headerSize(Header header) {
|
||||||
|
|
|
@ -122,8 +122,6 @@ public class SerdesInitializer {
|
||||||
registeredSerdes,
|
registeredSerdes,
|
||||||
Optional.ofNullable(clusterProperties.getDefaultKeySerde())
|
Optional.ofNullable(clusterProperties.getDefaultKeySerde())
|
||||||
.map(name -> Preconditions.checkNotNull(registeredSerdes.get(name), "Default key serde not found"))
|
.map(name -> Preconditions.checkNotNull(registeredSerdes.get(name), "Default key serde not found"))
|
||||||
.or(() -> Optional.ofNullable(registeredSerdes.get(SchemaRegistrySerde.name())))
|
|
||||||
.or(() -> Optional.ofNullable(registeredSerdes.get(ProtobufFileSerde.name())))
|
|
||||||
.orElse(null),
|
.orElse(null),
|
||||||
Optional.ofNullable(clusterProperties.getDefaultValueSerde())
|
Optional.ofNullable(clusterProperties.getDefaultValueSerde())
|
||||||
.map(name -> Preconditions.checkNotNull(registeredSerdes.get(name), "Default value serde not found"))
|
.map(name -> Preconditions.checkNotNull(registeredSerdes.get(name), "Default value serde not found"))
|
||||||
|
|
|
@ -109,6 +109,7 @@ public class KafkaConnectService {
|
||||||
private Stream<String> getStringsForSearch(FullConnectorInfoDTO fullConnectorInfo) {
|
private Stream<String> getStringsForSearch(FullConnectorInfoDTO fullConnectorInfo) {
|
||||||
return Stream.of(
|
return Stream.of(
|
||||||
fullConnectorInfo.getName(),
|
fullConnectorInfo.getName(),
|
||||||
|
fullConnectorInfo.getConnect(),
|
||||||
fullConnectorInfo.getStatus().getState().getValue(),
|
fullConnectorInfo.getStatus().getState().getValue(),
|
||||||
fullConnectorInfo.getType().getValue());
|
fullConnectorInfo.getType().getValue());
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,8 +43,7 @@ class TopicAnalysisStats {
|
||||||
Long max;
|
Long max;
|
||||||
final UpdateDoublesSketch sizeSketch = DoublesSketch.builder().build();
|
final UpdateDoublesSketch sizeSketch = DoublesSketch.builder().build();
|
||||||
|
|
||||||
void apply(byte[] bytes) {
|
void apply(int len) {
|
||||||
int len = bytes.length;
|
|
||||||
sum += len;
|
sum += len;
|
||||||
min = minNullable(min, len);
|
min = minNullable(min, len);
|
||||||
max = maxNullable(max, len);
|
max = maxNullable(max, len);
|
||||||
|
@ -98,7 +97,7 @@ class TopicAnalysisStats {
|
||||||
|
|
||||||
if (rec.key() != null) {
|
if (rec.key() != null) {
|
||||||
byte[] keyBytes = rec.key().get();
|
byte[] keyBytes = rec.key().get();
|
||||||
keysSize.apply(keyBytes);
|
keysSize.apply(rec.serializedKeySize());
|
||||||
uniqKeys.update(keyBytes);
|
uniqKeys.update(keyBytes);
|
||||||
} else {
|
} else {
|
||||||
nullKeys++;
|
nullKeys++;
|
||||||
|
@ -106,7 +105,7 @@ class TopicAnalysisStats {
|
||||||
|
|
||||||
if (rec.value() != null) {
|
if (rec.value() != null) {
|
||||||
byte[] valueBytes = rec.value().get();
|
byte[] valueBytes = rec.value().get();
|
||||||
valuesSize.apply(valueBytes);
|
valuesSize.apply(rec.serializedValueSize());
|
||||||
uniqValues.update(valueBytes);
|
uniqValues.update(valueBytes);
|
||||||
} else {
|
} else {
|
||||||
nullValues++;
|
nullValues++;
|
||||||
|
|
|
@ -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,12 +20,12 @@ 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.OauthAuthorityExtractor;
|
import com.provectus.kafka.ui.service.rbac.extractor.OauthAuthorityExtractor;
|
||||||
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;
|
||||||
|
@ -35,6 +36,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;
|
||||||
|
@ -51,10 +53,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() {
|
||||||
|
@ -64,22 +67,27 @@ 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 OAUTH -> new OauthAuthorityExtractor();
|
case OAUTH -> new OauthAuthorityExtractor();
|
||||||
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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -356,8 +364,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, Map<String, String> params) {
|
|
||||||
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -142,6 +142,8 @@ const Message: React.FC<Props> = ({
|
||||||
timestampType={timestampType}
|
timestampType={timestampType}
|
||||||
keySize={keySize}
|
keySize={keySize}
|
||||||
contentSize={valueSize}
|
contentSize={valueSize}
|
||||||
|
keySerde={keySerde}
|
||||||
|
valueSerde={valueSerde}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -3,7 +3,6 @@ import EditorViewer from 'components/common/EditorViewer/EditorViewer';
|
||||||
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
|
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
|
||||||
import { SchemaType, TopicMessageTimestampTypeEnum } from 'generated-sources';
|
import { SchemaType, TopicMessageTimestampTypeEnum } from 'generated-sources';
|
||||||
import { formatTimestamp } from 'lib/dateTimeHelpers';
|
import { formatTimestamp } from 'lib/dateTimeHelpers';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
import * as S from './MessageContent.styled';
|
import * as S from './MessageContent.styled';
|
||||||
|
|
||||||
|
@ -17,6 +16,8 @@ export interface MessageContentProps {
|
||||||
timestampType?: TopicMessageTimestampTypeEnum;
|
timestampType?: TopicMessageTimestampTypeEnum;
|
||||||
keySize?: number;
|
keySize?: number;
|
||||||
contentSize?: number;
|
contentSize?: number;
|
||||||
|
keySerde?: string;
|
||||||
|
valueSerde?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageContent: React.FC<MessageContentProps> = ({
|
const MessageContent: React.FC<MessageContentProps> = ({
|
||||||
|
@ -27,12 +28,10 @@ const MessageContent: React.FC<MessageContentProps> = ({
|
||||||
timestampType,
|
timestampType,
|
||||||
keySize,
|
keySize,
|
||||||
contentSize,
|
contentSize,
|
||||||
|
keySerde,
|
||||||
|
valueSerde,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = React.useState<Tab>('content');
|
const [activeTab, setActiveTab] = React.useState<Tab>('content');
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const keyFormat = searchParams.get('keySerde') || '';
|
|
||||||
const valueFormat = searchParams.get('valueSerde') || '';
|
|
||||||
|
|
||||||
const activeTabContent = () => {
|
const activeTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'content':
|
case 'content':
|
||||||
|
@ -110,7 +109,7 @@ const MessageContent: React.FC<MessageContentProps> = ({
|
||||||
<S.Metadata>
|
<S.Metadata>
|
||||||
<S.MetadataLabel>Key Serde</S.MetadataLabel>
|
<S.MetadataLabel>Key Serde</S.MetadataLabel>
|
||||||
<span>
|
<span>
|
||||||
<S.MetadataValue>{keyFormat}</S.MetadataValue>
|
<S.MetadataValue>{keySerde}</S.MetadataValue>
|
||||||
<S.MetadataMeta>
|
<S.MetadataMeta>
|
||||||
Size: <BytesFormatted value={keySize} />
|
Size: <BytesFormatted value={keySize} />
|
||||||
</S.MetadataMeta>
|
</S.MetadataMeta>
|
||||||
|
@ -120,7 +119,7 @@ const MessageContent: React.FC<MessageContentProps> = ({
|
||||||
<S.Metadata>
|
<S.Metadata>
|
||||||
<S.MetadataLabel>Value Serde</S.MetadataLabel>
|
<S.MetadataLabel>Value Serde</S.MetadataLabel>
|
||||||
<span>
|
<span>
|
||||||
<S.MetadataValue>{valueFormat}</S.MetadataValue>
|
<S.MetadataValue>{valueSerde}</S.MetadataValue>
|
||||||
<S.MetadataMeta>
|
<S.MetadataMeta>
|
||||||
Size: <BytesFormatted value={contentSize} />
|
Size: <BytesFormatted value={contentSize} />
|
||||||
</S.MetadataMeta>
|
</S.MetadataMeta>
|
||||||
|
|
|
@ -20,6 +20,8 @@ const setupWrapper = (props?: Partial<MessageContentProps>) => {
|
||||||
headers={{ header: 'test' }}
|
headers={{ header: 'test' }}
|
||||||
timestamp={new Date(0)}
|
timestamp={new Date(0)}
|
||||||
timestampType={TopicMessageTimestampTypeEnum.CREATE_TIME}
|
timestampType={TopicMessageTimestampTypeEnum.CREATE_TIME}
|
||||||
|
keySerde="SchemaRegistry"
|
||||||
|
valueSerde="Avro"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -27,42 +29,20 @@ const setupWrapper = (props?: Partial<MessageContentProps>) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const proto =
|
|
||||||
'syntax = "proto3";\npackage com.provectus;\n\nmessage TestProtoRecord {\n string f1 = 1;\n int32 f2 = 2;\n}\n';
|
|
||||||
|
|
||||||
global.TextEncoder = TextEncoder;
|
global.TextEncoder = TextEncoder;
|
||||||
|
|
||||||
const searchParamsContentAVRO = new URLSearchParams({
|
|
||||||
keySerde: 'SchemaRegistry',
|
|
||||||
valueSerde: 'AVRO',
|
|
||||||
limit: '100',
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchParamsContentJSON = new URLSearchParams({
|
|
||||||
keySerde: 'SchemaRegistry',
|
|
||||||
valueSerde: 'JSON',
|
|
||||||
limit: '100',
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchParamsContentPROTOBUF = new URLSearchParams({
|
|
||||||
keySerde: 'SchemaRegistry',
|
|
||||||
valueSerde: 'PROTOBUF',
|
|
||||||
limit: '100',
|
|
||||||
});
|
|
||||||
describe('MessageContent screen', () => {
|
describe('MessageContent screen', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
render(setupWrapper(), {
|
render(setupWrapper());
|
||||||
initialEntries: [`/messages?${searchParamsContentAVRO}`],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renders', () => {
|
describe('Checking keySerde and valueSerde', () => {
|
||||||
it('key format in document', () => {
|
it('keySerde in document', () => {
|
||||||
expect(screen.getByText('SchemaRegistry')).toBeInTheDocument();
|
expect(screen.getByText('SchemaRegistry')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('content format in document', () => {
|
it('valueSerde in document', () => {
|
||||||
expect(screen.getByText('AVRO')).toBeInTheDocument();
|
expect(screen.getByText('Avro')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -98,42 +78,3 @@ describe('MessageContent screen', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('checking content type depend on message type', () => {
|
|
||||||
it('renders component with message having JSON type', () => {
|
|
||||||
render(
|
|
||||||
setupWrapper({
|
|
||||||
messageContent: '{"data": "test"}',
|
|
||||||
}),
|
|
||||||
{ initialEntries: [`/messages?${searchParamsContentJSON}`] }
|
|
||||||
);
|
|
||||||
expect(screen.getByText('JSON')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
it('renders component with message having AVRO type', () => {
|
|
||||||
render(
|
|
||||||
setupWrapper({
|
|
||||||
messageContent: '{"data": "test"}',
|
|
||||||
}),
|
|
||||||
{ initialEntries: [`/messages?${searchParamsContentAVRO}`] }
|
|
||||||
);
|
|
||||||
expect(screen.getByText('AVRO')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
it('renders component with message having PROTOBUF type', () => {
|
|
||||||
render(
|
|
||||||
setupWrapper({
|
|
||||||
messageContent: proto,
|
|
||||||
}),
|
|
||||||
{ initialEntries: [`/messages?${searchParamsContentPROTOBUF}`] }
|
|
||||||
);
|
|
||||||
expect(screen.getByText('PROTOBUF')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
it('renders component with message having no type which is equal to having PROTOBUF type', () => {
|
|
||||||
render(
|
|
||||||
setupWrapper({
|
|
||||||
messageContent: '',
|
|
||||||
}),
|
|
||||||
{ initialEntries: [`/messages?${searchParamsContentPROTOBUF}`] }
|
|
||||||
);
|
|
||||||
expect(screen.getByText('PROTOBUF')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,52 +1,38 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import WarningIcon from 'components/common/Icons/WarningIcon';
|
import WarningIcon from 'components/common/Icons/WarningIcon';
|
||||||
import { gitCommitPath } from 'lib/paths';
|
import { gitCommitPath } from 'lib/paths';
|
||||||
import { useActuatorInfo } from 'lib/hooks/api/actuatorInfo';
|
|
||||||
import { BUILD_VERSION_PATTERN } from 'lib/constants';
|
|
||||||
import { useLatestVersion } from 'lib/hooks/api/latestVersion';
|
import { useLatestVersion } from 'lib/hooks/api/latestVersion';
|
||||||
import { formatTimestamp } from 'lib/dateTimeHelpers';
|
import { formatTimestamp } from 'lib/dateTimeHelpers';
|
||||||
|
|
||||||
import * as S from './Version.styled';
|
import * as S from './Version.styled';
|
||||||
import compareVersions from './compareVersions';
|
|
||||||
|
|
||||||
const Version: React.FC = () => {
|
const Version: React.FC = () => {
|
||||||
const { data: actuatorInfo = {} } = useActuatorInfo();
|
|
||||||
const { data: latestVersionInfo = {} } = useLatestVersion();
|
const { data: latestVersionInfo = {} } = useLatestVersion();
|
||||||
|
const { buildTime, commitId, isLatestRelease } = latestVersionInfo.build;
|
||||||
const tag = actuatorInfo?.build?.version;
|
const { versionTag } = latestVersionInfo?.latestRelease || '';
|
||||||
const commit = actuatorInfo?.git?.commit.id;
|
|
||||||
const { tag_name: latestTag } = latestVersionInfo;
|
|
||||||
|
|
||||||
const outdated = compareVersions(tag, latestTag);
|
|
||||||
|
|
||||||
const currentVersion = tag?.match(BUILD_VERSION_PATTERN)
|
|
||||||
? tag
|
|
||||||
: formatTimestamp(actuatorInfo?.build?.time);
|
|
||||||
|
|
||||||
if (!tag) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<S.Wrapper>
|
<S.Wrapper>
|
||||||
{!!outdated && (
|
{!isLatestRelease && (
|
||||||
<S.OutdatedWarning
|
<S.OutdatedWarning
|
||||||
title={`Your app version is outdated. Current latest version is ${latestTag}`}
|
title={`Your app version is outdated. Current latest version is ${versionTag}`}
|
||||||
>
|
>
|
||||||
<WarningIcon />
|
<WarningIcon />
|
||||||
</S.OutdatedWarning>
|
</S.OutdatedWarning>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{commit && (
|
{commitId && (
|
||||||
<div>
|
<div>
|
||||||
<S.CurrentCommitLink
|
<S.CurrentCommitLink
|
||||||
title="Current commit"
|
title="Current commit"
|
||||||
target="__blank"
|
target="__blank"
|
||||||
href={gitCommitPath(commit)}
|
href={gitCommitPath(commitId)}
|
||||||
>
|
>
|
||||||
{commit}
|
{commitId}
|
||||||
</S.CurrentCommitLink>
|
</S.CurrentCommitLink>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<S.CurrentVersion>{currentVersion}</S.CurrentVersion>
|
<S.CurrentVersion>{formatTimestamp(buildTime)}</S.CurrentVersion>
|
||||||
</S.Wrapper>
|
</S.Wrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,87 +2,40 @@ import React from 'react';
|
||||||
import { screen } from '@testing-library/dom';
|
import { screen } from '@testing-library/dom';
|
||||||
import Version from 'components/Version/Version';
|
import Version from 'components/Version/Version';
|
||||||
import { render } from 'lib/testHelpers';
|
import { render } from 'lib/testHelpers';
|
||||||
import { formatTimestamp } from 'lib/dateTimeHelpers';
|
|
||||||
import { useActuatorInfo } from 'lib/hooks/api/actuatorInfo';
|
|
||||||
import { useLatestVersion } from 'lib/hooks/api/latestVersion';
|
import { useLatestVersion } from 'lib/hooks/api/latestVersion';
|
||||||
import { actuatorInfoPayload } from 'lib/fixtures/actuatorInfo';
|
import {
|
||||||
import { latestVersionPayload } from 'lib/fixtures/latestVersion';
|
deprecatedVersionPayload,
|
||||||
|
latestVersionPayload,
|
||||||
|
} from 'lib/fixtures/latestVersion';
|
||||||
|
|
||||||
jest.mock('lib/hooks/api/actuatorInfo', () => ({
|
|
||||||
useActuatorInfo: jest.fn(),
|
|
||||||
}));
|
|
||||||
jest.mock('lib/hooks/api/latestVersion', () => ({
|
jest.mock('lib/hooks/api/latestVersion', () => ({
|
||||||
useLatestVersion: jest.fn(),
|
useLatestVersion: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Version Component', () => {
|
describe('Version Component', () => {
|
||||||
const versionTag = 'v0.5.0';
|
const commitId = '96a577a';
|
||||||
const snapshotTag = 'test-SNAPSHOT';
|
|
||||||
const commitTag = 'befd3b328e2c9c7df57b0c5746561b2f7fee8813';
|
|
||||||
|
|
||||||
const actuatorVersionPayload = actuatorInfoPayload(versionTag);
|
|
||||||
const formattedTimestamp = formatTimestamp(actuatorVersionPayload.build.time);
|
|
||||||
|
|
||||||
|
describe('render latest version', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(useActuatorInfo as jest.Mock).mockImplementation(() => ({
|
|
||||||
data: actuatorVersionPayload,
|
|
||||||
}));
|
|
||||||
(useLatestVersion as jest.Mock).mockImplementation(() => ({
|
(useLatestVersion as jest.Mock).mockImplementation(() => ({
|
||||||
data: latestVersionPayload,
|
data: latestVersionPayload,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
it('renders latest release version as current version', async () => {
|
||||||
|
render(<Version />);
|
||||||
|
expect(screen.getByText(commitId)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
describe('tag does not exist', () => {
|
it('should not show warning icon if it is last release', async () => {
|
||||||
it('does not render component', async () => {
|
render(<Version />);
|
||||||
(useActuatorInfo as jest.Mock).mockImplementation(() => ({
|
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||||
data: null,
|
|
||||||
}));
|
|
||||||
const { container } = render(<Version />);
|
|
||||||
expect(container.firstChild).toBeEmptyDOMElement();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renders current version', () => {
|
it('show warning icon if it is not last release', async () => {
|
||||||
it('renders release build version as current version', async () => {
|
(useLatestVersion as jest.Mock).mockImplementation(() => ({
|
||||||
render(<Version />);
|
data: deprecatedVersionPayload,
|
||||||
expect(screen.getByText(versionTag)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
it('renders formatted timestamp as current version when version is commit', async () => {
|
|
||||||
(useActuatorInfo as jest.Mock).mockImplementation(() => ({
|
|
||||||
data: actuatorInfoPayload(commitTag),
|
|
||||||
}));
|
}));
|
||||||
render(<Version />);
|
render(<Version />);
|
||||||
expect(screen.getByText(formattedTimestamp)).toBeInTheDocument();
|
expect(screen.getByRole('img')).toBeInTheDocument();
|
||||||
});
|
|
||||||
it('renders formatted timestamp as current version when version contains -SNAPSHOT', async () => {
|
|
||||||
(useActuatorInfo as jest.Mock).mockImplementation(() => ({
|
|
||||||
data: actuatorInfoPayload(snapshotTag),
|
|
||||||
}));
|
|
||||||
render(<Version />);
|
|
||||||
expect(screen.getByText(formattedTimestamp)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('outdated build version', () => {
|
|
||||||
it('renders warning message', async () => {
|
|
||||||
(useActuatorInfo as jest.Mock).mockImplementation(() => ({
|
|
||||||
data: actuatorInfoPayload('v0.3.0'),
|
|
||||||
}));
|
|
||||||
render(<Version />);
|
|
||||||
expect(
|
|
||||||
screen.getByTitle(
|
|
||||||
`Your app version is outdated. Current latest version is ${latestVersionPayload.tag_name}`
|
|
||||||
)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('current commit id with link', () => {
|
|
||||||
it('renders', async () => {
|
|
||||||
render(<Version />);
|
|
||||||
expect(
|
|
||||||
screen.getByText(actuatorVersionPayload.git.commit.id)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,6 +13,7 @@ const WarningIcon: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<WarningIconContainer>
|
<WarningIconContainer>
|
||||||
<svg
|
<svg
|
||||||
|
role="img"
|
||||||
width="14"
|
width="14"
|
||||||
height="13"
|
height="13"
|
||||||
viewBox="0 0 14 13"
|
viewBox="0 0 14 13"
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
export const actuatorInfoPayload = (
|
|
||||||
version = 'befd3b328e2c9c7df57b0c5746561b2f7fee8813'
|
|
||||||
) => ({
|
|
||||||
git: { commit: { id: 'befd3b3' } },
|
|
||||||
build: {
|
|
||||||
artifact: 'kafka-ui-api',
|
|
||||||
name: 'kafka-ui-api',
|
|
||||||
time: '2022-09-15T09:52:21.753Z',
|
|
||||||
version,
|
|
||||||
group: 'com.provectus',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,3 +1,16 @@
|
||||||
export const latestVersionPayload = {
|
export const deprecatedVersionPayload = {
|
||||||
tag_name: 'v0.4.0',
|
build: {
|
||||||
|
buildTime: '2023-04-14T09:47:35.463Z',
|
||||||
|
commitId: '96a577a',
|
||||||
|
isLatestRelease: false,
|
||||||
|
version: '96a577a98c6069376c5d22ed49cffd3739f1bbdc',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const latestVersionPayload = {
|
||||||
|
build: {
|
||||||
|
buildTime: '2023-04-14T09:47:35.463Z',
|
||||||
|
commitId: '96a577a',
|
||||||
|
isLatestRelease: true,
|
||||||
|
version: '96a577a98c6069376c5d22ed49cffd3739f1bbdc',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import fetchMock from 'fetch-mock';
|
|
||||||
import * as hooks from 'lib/hooks/api/actuatorInfo';
|
|
||||||
import { expectQueryWorks, renderQueryHook } from 'lib/testHelpers';
|
|
||||||
import { actuatorInfoPayload } from 'lib/fixtures/actuatorInfo';
|
|
||||||
|
|
||||||
const actuatorInfoPath = '/actuator/info';
|
|
||||||
|
|
||||||
describe('Actuator info hooks', () => {
|
|
||||||
beforeEach(() => fetchMock.restore());
|
|
||||||
describe('useActuatorInfo', () => {
|
|
||||||
it('returns the correct data', async () => {
|
|
||||||
const mock = fetchMock.getOnce(actuatorInfoPath, actuatorInfoPayload());
|
|
||||||
const { result } = renderQueryHook(() => hooks.useActuatorInfo());
|
|
||||||
await expectQueryWorks(mock, result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,18 +1,16 @@
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import { expectQueryWorks, renderQueryHook } from 'lib/testHelpers';
|
import { expectQueryWorks, renderQueryHook } from 'lib/testHelpers';
|
||||||
import * as hooks from 'lib/hooks/api/latestVersion';
|
|
||||||
import { GIT_REPO_LATEST_RELEASE_LINK } from 'lib/constants';
|
|
||||||
import { latestVersionPayload } from 'lib/fixtures/latestVersion';
|
import { latestVersionPayload } from 'lib/fixtures/latestVersion';
|
||||||
|
import { useLatestVersion } from 'lib/hooks/api/latestVersion';
|
||||||
|
|
||||||
|
const latestVersionPath = '/api/info';
|
||||||
|
|
||||||
describe('Latest version hooks', () => {
|
describe('Latest version hooks', () => {
|
||||||
beforeEach(() => fetchMock.restore());
|
beforeEach(() => fetchMock.restore());
|
||||||
describe('useLatestVersion', () => {
|
describe('useLatestVersion', () => {
|
||||||
it('returns the correct data', async () => {
|
it('returns the correct data', async () => {
|
||||||
const mock = fetchMock.getOnce(
|
const mock = fetchMock.getOnce(latestVersionPath, latestVersionPayload);
|
||||||
GIT_REPO_LATEST_RELEASE_LINK,
|
const { result } = renderQueryHook(() => useLatestVersion());
|
||||||
latestVersionPayload
|
|
||||||
);
|
|
||||||
const { result } = renderQueryHook(() => hooks.useLatestVersion());
|
|
||||||
await expectQueryWorks(mock, result);
|
await expectQueryWorks(mock, result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { BASE_PARAMS, QUERY_REFETCH_OFF_OPTIONS } from 'lib/constants';
|
|
||||||
|
|
||||||
const fetchActuatorInfo = async () => {
|
|
||||||
const data = await fetch(
|
|
||||||
`${BASE_PARAMS.basePath}/actuator/info`,
|
|
||||||
BASE_PARAMS
|
|
||||||
).then((res) => res.json());
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useActuatorInfo() {
|
|
||||||
return useQuery(
|
|
||||||
['actuatorInfo'],
|
|
||||||
fetchActuatorInfo,
|
|
||||||
QUERY_REFETCH_OFF_OPTIONS
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,21 +1,19 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import { BASE_PARAMS, QUERY_REFETCH_OFF_OPTIONS } from 'lib/constants';
|
||||||
QUERY_REFETCH_OFF_OPTIONS,
|
|
||||||
GIT_REPO_LATEST_RELEASE_LINK,
|
|
||||||
} from 'lib/constants';
|
|
||||||
|
|
||||||
const fetchLatestVersion = async () => {
|
const fetchLatestVersionInfo = async () => {
|
||||||
const data = await fetch(GIT_REPO_LATEST_RELEASE_LINK).then((res) =>
|
const data = await fetch(
|
||||||
res.json()
|
`${BASE_PARAMS.basePath}/api/info`,
|
||||||
);
|
BASE_PARAMS
|
||||||
|
).then((res) => res.json());
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useLatestVersion() {
|
export function useLatestVersion() {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
['latestVersion'],
|
['versionInfo'],
|
||||||
fetchLatestVersion,
|
fetchLatestVersionInfo,
|
||||||
QUERY_REFETCH_OFF_OPTIONS
|
QUERY_REFETCH_OFF_OPTIONS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue