Merge branch 'master' into ISSUE_754_acl

This commit is contained in:
Ilya Kuramshin 2023-05-02 10:58:41 +04:00 committed by GitHub
commit 0be17bc7af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 460 additions and 171 deletions

View file

@ -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;

View file

@ -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;
}

View file

@ -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);
}
} }
} }

View file

@ -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();

View file

@ -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();
}
}

View file

@ -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 {
}
}

View file

@ -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()),

View file

@ -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);
}

View file

@ -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();

View file

@ -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);

View file

@ -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

View file

@ -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);
} }

View file

@ -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() {

View file

@ -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
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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);
} }

View file

@ -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);
} }
} }

View file

@ -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);
} }
} }

View file

@ -3809,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

View file

@ -29,72 +29,65 @@ public class SmokeBacklog extends BaseManualTest {
} }
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = KSQL_DB_SUITE_ID) @Suite(id = BROKERS_SUITE_ID)
@QaseId(278) @QaseId(331)
@Test @Test
public void testCaseC() { public void testCaseC() {
} }
@Automation(state = TO_BE_AUTOMATED)
@Suite(id = BROKERS_SUITE_ID)
@QaseId(331)
@Test
public void testCaseD() {
}
@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 testCaseD() {
} }
@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 testCaseE() {
} }
@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 testCaseF() {
} }
@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 testCaseG() {
} }
@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 testCaseH() {
} }
@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 testCaseI() {
} }
@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 testCaseJ() {
} }
@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 testCaseK() {
} }
} }

View file

@ -1,6 +1,7 @@
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.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 +81,18 @@ 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(), "getItemByName()");
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();

View file

@ -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;
`;

View file

@ -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>

View file

@ -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();
});
});
}); });