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..78b22d9bd 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 @@ -1,6 +1,8 @@ package io.kafbat.ui.config.auth; +import io.kafbat.ui.model.rbac.DefaultRole; import io.kafbat.ui.model.rbac.Role; +import jakarta.annotation.Nullable; import jakarta.annotation.PostConstruct; import java.util.ArrayList; import java.util.List; @@ -11,13 +13,26 @@ public class RoleBasedAccessControlProperties { private final List roles = new ArrayList<>(); + private DefaultRole defaultRole; + @PostConstruct public void init() { roles.forEach(Role::validate); + if (defaultRole != null) { + defaultRole.validate(); + } } public List getRoles() { return roles; } + public void setDefaultRole(DefaultRole defaultRole) { + this.defaultRole = defaultRole; + } + + @Nullable + public DefaultRole 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/DefaultRole.java b/api/src/main/java/io/kafbat/ui/model/rbac/DefaultRole.java new file mode 100644 index 000000000..2caa10ecd --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/model/rbac/DefaultRole.java @@ -0,0 +1,21 @@ +package io.kafbat.ui.model.rbac; + +import static com.google.common.base.Preconditions.checkArgument; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +@Data +public class DefaultRole { + + private String name; + private List clusters; + private List permissions = new ArrayList<>(); + + public void validate() { + checkArgument(clusters != null && !clusters.isEmpty(), "Default role clusters cannot be empty"); + permissions.forEach(Permission::validate); + permissions.forEach(Permission::transform); + } +} \ No newline at end of file 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..c2ec0f556 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 @@ -21,5 +21,4 @@ public void validate() { permissions.forEach(Permission::transform); subjects.forEach(Subject::validate); } - } 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..ff780b596 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 @@ -7,6 +7,7 @@ import io.kafbat.ui.model.ConnectDTO; import io.kafbat.ui.model.InternalTopic; import io.kafbat.ui.model.rbac.AccessContext; +import io.kafbat.ui.model.rbac.DefaultRole; import io.kafbat.ui.model.rbac.Permission; import io.kafbat.ui.model.rbac.Role; import io.kafbat.ui.model.rbac.Subject; @@ -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,7 +65,7 @@ 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; } @@ -86,7 +89,7 @@ public void init() { .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 +117,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() { @@ -132,10 +143,15 @@ public static Mono getUser() { private boolean isClusterAccessible(String clusterName, AuthenticatedUser user) { Assert.isTrue(StringUtils.isNotEmpty(clusterName), "cluster value is empty"); - return properties.getRoles() + boolean isAccessible = properties.getRoles() .stream() .filter(filterRole(user)) .anyMatch(role -> role.getClusters().stream().anyMatch(clusterName::equalsIgnoreCase)); + + if (!isAccessible && properties.getDefaultRole() != null) { + return properties.getDefaultRole().getClusters().stream().anyMatch(clusterName::equalsIgnoreCase); + } + return isAccessible; } public Mono isClusterAccessible(ClusterDTO cluster) { @@ -200,6 +216,10 @@ public List getRoles() { return Collections.unmodifiableList(properties.getRoles()); } + public DefaultRole getDefaultRole() { + return properties.getDefaultRole(); + } + private Predicate filterRole(AuthenticatedUser user) { return role -> user.groups().contains(role.getName()); } 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..1f3e2c1cf --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/service/rbac/AccessControlServiceDefaultRoleRbacEnabledTest.java @@ -0,0 +1,111 @@ +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.DefaultRole; +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.access.AccessDeniedException; +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 + DefaultRole defaultRole; + + @Mock + ClustersStorage clustersStorage; + + @BeforeEach + void setUp() { + + RoleBasedAccessControlProperties properties = mock(); + defaultRole = MockedRbacUtils.getDefaultRole(); + when(properties.getDefaultRole()).thenReturn(defaultRole); + when(properties.getRoles()).thenReturn(List.of()); // Return empty list for roles + + + ReflectionTestUtils.setField(accessControlService, "properties", properties); + ReflectionTestUtils.setField(accessControlService, "rbacEnabled", true); + + // 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 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(); + }); + } +} 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..c883e8d44 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 @@ -5,6 +5,7 @@ import static org.mockito.Mockito.when; import io.kafbat.ui.model.rbac.AccessContext; +import io.kafbat.ui.model.rbac.DefaultRole; import io.kafbat.ui.model.rbac.Permission; import io.kafbat.ui.model.rbac.Resource; import io.kafbat.ui.model.rbac.Role; @@ -20,6 +21,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 +101,42 @@ public static Role getDevRole() { return role; } + public static DefaultRole getDefaultRole() { + DefaultRole role = new DefaultRole(); + role.setName(DEFAULT_ROLE); + role.setClusters(List.of(DEV_CLUSTER, PROD_CLUSTER)); + + 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.validate(); + return role; + } + public static AccessContext getAccessContext(String cluster, Boolean resourceAccessible) { AccessContext.ResourceAccess mockedResource = mock(AccessContext.ResourceAccess.class); when(mockedResource.isAccessible(any())).thenReturn(resourceAccessible);