diff --git a/api/src/main/java/io/kafbat/ui/config/auth/RoleBasedAccessControlProperties.java b/api/src/main/java/io/kafbat/ui/config/auth/RoleBasedAccessControlProperties.java index 8ecf12b99..abe62a0f7 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/RoleBasedAccessControlProperties.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/RoleBasedAccessControlProperties.java @@ -4,6 +4,7 @@ import jakarta.annotation.PostConstruct; import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties("rbac") @@ -11,13 +12,26 @@ public class RoleBasedAccessControlProperties { private final List roles = new ArrayList<>(); + private Role defaultRole; + @PostConstruct public void init() { roles.forEach(Role::validate); + if (defaultRole != null) { + defaultRole.validateDefaultRole(); + } } public List getRoles() { return roles; } + public void setDefaultRole(Role defaultRole) { + this.defaultRole = defaultRole; + } + + @Nullable + public Role getDefaultRole() { + return defaultRole; + } } diff --git a/api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java b/api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java index aac1ab6fa..ee5db08a5 100644 --- a/api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java +++ b/api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java @@ -31,6 +31,12 @@ public class AuthorizationController implements AuthorizationApi { private final AccessControlService accessControlService; public Mono> getUserAuthInfo(ServerWebExchange exchange) { + List defaultRolePermissions = accessControlService.getDefaultRole() != null + ? mapPermissions( + accessControlService.getDefaultRole().getPermissions(), + accessControlService.getDefaultRole().getClusters()) + : Collections.emptyList(); + Mono> permissions = AccessControlService.getUser() .map(user -> accessControlService.getRoles() .stream() @@ -39,6 +45,8 @@ public Mono> getUserAuthInfo(ServerWebExch .flatMap(Collection::stream) .toList() ) + // if no roles are found, return default role permissions + .map(userPermissions -> userPermissions.isEmpty() ? defaultRolePermissions : userPermissions) .switchIfEmpty(Mono.just(Collections.emptyList())); Mono userName = ReactiveSecurityContextHolder.getContext() diff --git a/api/src/main/java/io/kafbat/ui/model/rbac/Role.java b/api/src/main/java/io/kafbat/ui/model/rbac/Role.java index 068b46625..473c39990 100644 --- a/api/src/main/java/io/kafbat/ui/model/rbac/Role.java +++ b/api/src/main/java/io/kafbat/ui/model/rbac/Role.java @@ -22,4 +22,9 @@ public void validate() { subjects.forEach(Subject::validate); } + // default role need only permissions + public void validateDefaultRole() { + permissions.forEach(Permission::validate); + permissions.forEach(Permission::transform); + } } diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/AccessControlService.java b/api/src/main/java/io/kafbat/ui/service/rbac/AccessControlService.java index 88a966013..3ab53e670 100644 --- a/api/src/main/java/io/kafbat/ui/service/rbac/AccessControlService.java +++ b/api/src/main/java/io/kafbat/ui/service/rbac/AccessControlService.java @@ -6,6 +6,7 @@ import io.kafbat.ui.model.ClusterDTO; import io.kafbat.ui.model.ConnectDTO; import io.kafbat.ui.model.InternalTopic; +import io.kafbat.ui.model.KafkaCluster; import io.kafbat.ui.model.rbac.AccessContext; import io.kafbat.ui.model.rbac.Permission; import io.kafbat.ui.model.rbac.Role; @@ -14,6 +15,7 @@ import io.kafbat.ui.model.rbac.permission.ConsumerGroupAction; import io.kafbat.ui.model.rbac.permission.SchemaAction; import io.kafbat.ui.model.rbac.permission.TopicAction; +import io.kafbat.ui.service.ClustersStorage; import io.kafbat.ui.service.rbac.extractor.CognitoAuthorityExtractor; import io.kafbat.ui.service.rbac.extractor.GithubAuthorityExtractor; import io.kafbat.ui.service.rbac.extractor.GoogleAuthorityExtractor; @@ -53,6 +55,7 @@ public class AccessControlService { @Nullable private final InMemoryReactiveClientRegistrationRepository clientRegistrationRepository; private final RoleBasedAccessControlProperties properties; + private final ClustersStorage clustersStorage; private final Environment environment; @Getter @@ -62,13 +65,31 @@ public class AccessControlService { @PostConstruct public void init() { - if (CollectionUtils.isEmpty(properties.getRoles())) { + if (CollectionUtils.isEmpty(properties.getRoles()) && properties.getDefaultRole() == null) { log.trace("No roles provided, disabling RBAC"); return; } + if (properties.getDefaultRole() != null) { + log.trace("Set Default Role Clusters"); + // set default role for all clusters + properties.getDefaultRole().setClusters( + clustersStorage.getKafkaClusters().stream() + .map(KafkaCluster::getName) + .collect(Collectors.toList()) + ); + } rbacEnabled = true; - this.oauthExtractors = properties.getRoles() + if (properties.getDefaultRole() != null) { + // set all extractors for default role because it is applied to all clusters + this.oauthExtractors = Set.of( + new CognitoAuthorityExtractor(), + new GoogleAuthorityExtractor(), + new GithubAuthorityExtractor(), + new OauthAuthorityExtractor() + ); + } else { + this.oauthExtractors = properties.getRoles() .stream() .map(role -> role.getSubjects() .stream() @@ -85,8 +106,9 @@ public void init() { .collect(Collectors.toSet())) .flatMap(Set::stream) .collect(Collectors.toSet()); + } - if (!properties.getRoles().isEmpty() + if (!(properties.getRoles().isEmpty() && properties.getDefaultRole() == null) && "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."); @@ -114,12 +136,20 @@ private boolean isAccessible(AuthenticatedUser user, AccessContext context) { } private List getUserPermissions(AuthenticatedUser user, @Nullable String clusterName) { - return properties.getRoles() - .stream() - .filter(filterRole(user)) - .filter(role -> clusterName == null || role.getClusters().stream().anyMatch(clusterName::equalsIgnoreCase)) - .flatMap(role -> role.getPermissions().stream()) - .toList(); + List filteredRoles = properties.getRoles() + .stream() + .filter(filterRole(user)) + .filter(role -> clusterName == null || role.getClusters().stream().anyMatch(clusterName::equalsIgnoreCase)) + .toList(); + + // if no roles are found, check if default role is set + if (filteredRoles.isEmpty() && properties.getDefaultRole() != null) { + return properties.getDefaultRole().getPermissions(); + } + + return filteredRoles.stream() + .flatMap(role -> role.getPermissions().stream()) + .toList(); } public static Mono getUser() { @@ -200,6 +230,10 @@ public List getRoles() { return Collections.unmodifiableList(properties.getRoles()); } + public Role getDefaultRole() { + return properties.getDefaultRole(); + } + private Predicate filterRole(AuthenticatedUser user) { return role -> user.groups().contains(role.getName()); } diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/CognitoAuthorityExtractor.java b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/CognitoAuthorityExtractor.java index cc0e419bf..9cdf5ea62 100644 --- a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/CognitoAuthorityExtractor.java +++ b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/CognitoAuthorityExtractor.java @@ -39,8 +39,13 @@ public Mono> extract(AccessControlService acs, Object value, Map extractUsernameRoles(AccessControlService acs, DefaultOAuth2User principal) { diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/GithubAuthorityExtractor.java b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/GithubAuthorityExtractor.java index 79f4907fc..63182b999 100644 --- a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/GithubAuthorityExtractor.java +++ b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/GithubAuthorityExtractor.java @@ -71,10 +71,15 @@ public Mono> extract(AccessControlService acs, Object value, Map> organizationRoles = getOrganizationRoles(principal, additionalParams, acs, webClient); Mono> teamRoles = getTeamRoles(webClient, additionalParams, acs); + Set defaultRoles = acs.getDefaultRole() == null + ? Collections.emptySet() + : Set.of(acs.getDefaultRole().getName()); + return Mono.zip(organizationRoles, teamRoles) .map((t) -> Stream.of(t.getT1(), t.getT2(), usernameRoles) .flatMap(Collection::stream) - .collect(Collectors.toSet())); + .collect(Collectors.toSet())) + .map(roles -> roles.isEmpty() ? defaultRoles : roles); } private Set extractUsernameRoles(DefaultOAuth2User principal, AccessControlService acs) { diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/GoogleAuthorityExtractor.java b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/GoogleAuthorityExtractor.java index a90ab50ef..a0fd07d32 100644 --- a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/GoogleAuthorityExtractor.java +++ b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/GoogleAuthorityExtractor.java @@ -39,8 +39,13 @@ public Mono> extract(AccessControlService acs, Object value, Map extractUsernameRoles(AccessControlService acs, DefaultOAuth2User principal) { diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/OauthAuthorityExtractor.java b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/OauthAuthorityExtractor.java index 61748610e..2f827c6ae 100644 --- a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/OauthAuthorityExtractor.java +++ b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/OauthAuthorityExtractor.java @@ -43,8 +43,13 @@ public Mono> extract(AccessControlService acs, Object value, Map extractUsernameRoles(AccessControlService acs, DefaultOAuth2User principal) { diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacActiveDirectoryAuthoritiesExtractor.java b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacActiveDirectoryAuthoritiesExtractor.java index f7f5ec1d9..2f545a484 100644 --- a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacActiveDirectoryAuthoritiesExtractor.java +++ b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacActiveDirectoryAuthoritiesExtractor.java @@ -4,6 +4,7 @@ import io.kafbat.ui.model.rbac.provider.Provider; import io.kafbat.ui.service.rbac.AccessControlService; import java.util.Collection; +import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationContext; @@ -31,7 +32,7 @@ public Collection getGrantedAuthorities(DirContextOp .peek(group -> log.trace("Found AD group [{}] for user [{}]", group, username)) .collect(Collectors.toSet()); - return acs.getRoles() + var grantedAuthorities = acs.getRoles() .stream() .filter(r -> r.getSubjects() .stream() @@ -46,5 +47,11 @@ public Collection getGrantedAuthorities(DirContextOp .peek(role -> log.trace("Mapped role [{}] for user [{}]", role, username)) .map(SimpleGrantedAuthority::new) .collect(Collectors.toSet()); + + // If no roles are found, return default role + if (grantedAuthorities.isEmpty() && acs.getDefaultRole() != null) { + return Set.of(new SimpleGrantedAuthority(acs.getDefaultRole().getName())); + } + return grantedAuthorities; } } diff --git a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java index fd168bc5a..f19ddc8f8 100644 --- a/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java +++ b/api/src/main/java/io/kafbat/ui/service/rbac/extractor/RbacLdapAuthoritiesExtractor.java @@ -3,6 +3,7 @@ import io.kafbat.ui.model.rbac.Role; import io.kafbat.ui.model.rbac.provider.Provider; import io.kafbat.ui.service.rbac.AccessControlService; +import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -33,7 +34,7 @@ protected Set getAdditionalRoles(DirContextOperations user, St .peek(group -> log.trace("Found LDAP group [{}] for user [{}]", group, username)) .collect(Collectors.toSet()); - return acs.getRoles() + var simpleGrantedAuthorities = acs.getRoles() .stream() .filter(r -> r.getSubjects() .stream() @@ -48,5 +49,11 @@ protected Set getAdditionalRoles(DirContextOperations user, St .peek(role -> log.trace("Mapped role [{}] for user [{}]", role, username)) .map(SimpleGrantedAuthority::new) .collect(Collectors.toSet()); + + // If no roles are found, return default role + if (simpleGrantedAuthorities.isEmpty() && acs.getDefaultRole() != null) { + return Set.of(new SimpleGrantedAuthority(acs.getDefaultRole().getName())); + } + return new HashSet<>(simpleGrantedAuthorities); } } diff --git a/api/src/test/java/io/kafbat/ui/service/rbac/AccessControlServiceDefaultRoleRbacEnabledTest.java b/api/src/test/java/io/kafbat/ui/service/rbac/AccessControlServiceDefaultRoleRbacEnabledTest.java new file mode 100644 index 000000000..f3a83f031 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/service/rbac/AccessControlServiceDefaultRoleRbacEnabledTest.java @@ -0,0 +1,151 @@ +package io.kafbat.ui.service.rbac; + +import static io.kafbat.ui.service.rbac.MockedRbacUtils.DEFAULT_ROLE; +import static io.kafbat.ui.service.rbac.MockedRbacUtils.DEV_CLUSTER; +import static io.kafbat.ui.service.rbac.MockedRbacUtils.PROD_CLUSTER; +import static io.kafbat.ui.service.rbac.MockedRbacUtils.getAccessContext; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.kafbat.ui.AbstractIntegrationTest; +import io.kafbat.ui.config.auth.RbacUser; +import io.kafbat.ui.config.auth.RoleBasedAccessControlProperties; +import io.kafbat.ui.model.ClusterDTO; +import io.kafbat.ui.model.KafkaCluster; +import io.kafbat.ui.model.rbac.AccessContext; +import io.kafbat.ui.model.rbac.Role; +import io.kafbat.ui.service.ClustersStorage; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + + +/** + * Test class for AccessControlService with default role and RBAC enabled. + */ +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +public class AccessControlServiceDefaultRoleRbacEnabledTest extends AbstractIntegrationTest { + + @Autowired + AccessControlService accessControlService; + + @Mock + SecurityContext securityContext; + + @Mock + Authentication authentication; + + @Mock + RbacUser user; + + @Mock + Role defaultRole; + + @Mock + ClustersStorage clustersStorage; + + @BeforeEach + void setUp() { + + RoleBasedAccessControlProperties properties = mock(); + defaultRole = MockedRbacUtils.getDefaultRole(); + when(properties.getDefaultRole()).thenReturn(defaultRole); + + + ReflectionTestUtils.setField(accessControlService, "properties", properties); + ReflectionTestUtils.setField(accessControlService, "rbacEnabled", true); + ReflectionTestUtils.setField(accessControlService, "clustersStorage", clustersStorage); + + KafkaCluster prodCluster = KafkaCluster.builder().name(PROD_CLUSTER).build(); + KafkaCluster devCluster = KafkaCluster.builder().name(DEV_CLUSTER).build(); + + // set default role for all clusters + when(clustersStorage.getKafkaClusters()).thenReturn(List.of(prodCluster, devCluster)); + accessControlService.init(); + + // Mock security context + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(user); + } + + public void withSecurityContext(Runnable runnable) { + try (MockedStatic ctxHolder = Mockito.mockStatic( + ReactiveSecurityContextHolder.class)) { + // Mock static method to get security context + ctxHolder.when(ReactiveSecurityContextHolder::getContext).thenReturn(Mono.just(securityContext)); + runnable.run(); + } + } + + @Test + void validateSetCluster() { + withSecurityContext(() -> { + + List clusters = defaultRole.getClusters(); + assertThat(clusters) + .isNotNull() + .containsExactlyInAnyOrder(PROD_CLUSTER, DEV_CLUSTER); + }); + } + + @Test + void validateAccess() { + withSecurityContext(() -> { + when(user.groups()).thenReturn(List.of(DEFAULT_ROLE)); + AccessContext context = getAccessContext(PROD_CLUSTER, true); + Mono validateAccessMono = accessControlService.validateAccess(context); + StepVerifier.create(validateAccessMono) + .expectComplete() + .verify(); + }); + } + + @Test + void isClusterAccessible() { + withSecurityContext(() -> { + ClusterDTO clusterDto = new ClusterDTO(); + clusterDto.setName(PROD_CLUSTER); + Mono clusterAccessibleMono = accessControlService.isClusterAccessible(clusterDto); + StepVerifier.create(clusterAccessibleMono) + .expectNext(true) + .expectComplete() + .verify(); + }); + } + + /** + * Test for isClusterAccessible with unknown cluster. + */ + @Test + void isClusterAccessible_unknownCluster() { + withSecurityContext(() -> { + when(user.groups()).thenReturn(List.of(DEFAULT_ROLE)); + ClusterDTO clusterDto = new ClusterDTO(); + clusterDto.setName("unknown"); + Mono clusterAccessibleMono = accessControlService.isClusterAccessible(clusterDto); + StepVerifier.create(clusterAccessibleMono) + .expectNext(false) + .expectComplete() + .verify(); + }); + } + + @Test + void testGetDefaultRole() { + Role defaultRole = accessControlService.getDefaultRole(); + assertThat(defaultRole).isNotNull() + .isEqualTo(this.defaultRole); + } +} diff --git a/api/src/test/java/io/kafbat/ui/service/rbac/MockedRbacUtils.java b/api/src/test/java/io/kafbat/ui/service/rbac/MockedRbacUtils.java index a59322014..ceac8d088 100644 --- a/api/src/test/java/io/kafbat/ui/service/rbac/MockedRbacUtils.java +++ b/api/src/test/java/io/kafbat/ui/service/rbac/MockedRbacUtils.java @@ -20,6 +20,7 @@ public class MockedRbacUtils { public static final String ADMIN_ROLE = "admin_role"; public static final String DEV_ROLE = "dev_role"; + public static final String DEFAULT_ROLE = "default_role"; public static final String PROD_CLUSTER = "prod"; public static final String DEV_CLUSTER = "dev"; @@ -99,6 +100,41 @@ public static Role getDevRole() { return role; } + public static Role getDefaultRole() { + Role role = new Role(); + role.setName(DEFAULT_ROLE); + + Permission topicViewPermission = new Permission(); + topicViewPermission.setResource(Resource.TOPIC.name()); + topicViewPermission.setActions(List.of(TopicAction.VIEW.name())); + topicViewPermission.setValue(TOPIC_NAME); + + Permission consumerGroupPermission = new Permission(); + consumerGroupPermission.setResource(Resource.CONSUMER.name()); + consumerGroupPermission.setActions(List.of(ConsumerGroupAction.VIEW.name())); + consumerGroupPermission.setValue(CONSUMER_GROUP_NAME); + + Permission schemaPermission = new Permission(); + schemaPermission.setResource(Resource.SCHEMA.name()); + schemaPermission.setActions(List.of(SchemaAction.VIEW.name())); + schemaPermission.setValue(SCHEMA_NAME); + + Permission connectPermission = new Permission(); + connectPermission.setResource(Resource.CONNECT.name()); + connectPermission.setActions(List.of(ConnectAction.VIEW.name())); + connectPermission.setValue(CONNECT_NAME); + + List permissions = List.of( + topicViewPermission, + consumerGroupPermission, + schemaPermission, + connectPermission + ); + role.setPermissions(permissions); + role.validateDefaultRole(); + return role; + } + public static AccessContext getAccessContext(String cluster, Boolean resourceAccessible) { AccessContext.ResourceAccess mockedResource = mock(AccessContext.ResourceAccess.class); when(mockedResource.isAccessible(any())).thenReturn(resourceAccessible);