Skip to content

Commit 089b072

Browse files
seonoseonho-jeonggermanosinHaarolean
authored
BE: RBAC: Implement instance-wide default role (#1056)
Co-authored-by: seonho-jeong <seonho.jeong@navercorp.com> Co-authored-by: German Osin <german.osin@gmail.com> Co-authored-by: Roman Zabaluev <gpg@haarolean.dev>
1 parent 86b8cac commit 089b072

File tree

8 files changed

+222
-10
lines changed

8 files changed

+222
-10
lines changed

api/src/main/java/io/kafbat/ui/config/auth/RoleBasedAccessControlProperties.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.kafbat.ui.config.auth;
22

3+
import io.kafbat.ui.model.rbac.DefaultRole;
34
import io.kafbat.ui.model.rbac.Role;
5+
import jakarta.annotation.Nullable;
46
import jakarta.annotation.PostConstruct;
57
import java.util.ArrayList;
68
import java.util.List;
@@ -11,13 +13,26 @@ public class RoleBasedAccessControlProperties {
1113

1214
private final List<Role> roles = new ArrayList<>();
1315

16+
private DefaultRole defaultRole;
17+
1418
@PostConstruct
1519
public void init() {
1620
roles.forEach(Role::validate);
21+
if (defaultRole != null) {
22+
defaultRole.validate();
23+
}
1724
}
1825

1926
public List<Role> getRoles() {
2027
return roles;
2128
}
2229

30+
public void setDefaultRole(DefaultRole defaultRole) {
31+
this.defaultRole = defaultRole;
32+
}
33+
34+
@Nullable
35+
public DefaultRole getDefaultRole() {
36+
return defaultRole;
37+
}
2338
}

api/src/main/java/io/kafbat/ui/controller/AuthorizationController.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import io.kafbat.ui.api.AuthorizationApi;
44
import io.kafbat.ui.model.ActionDTO;
55
import io.kafbat.ui.model.AuthenticationInfoDTO;
6+
import io.kafbat.ui.model.KafkaCluster;
67
import io.kafbat.ui.model.ResourceTypeDTO;
78
import io.kafbat.ui.model.UserInfoDTO;
89
import io.kafbat.ui.model.UserPermissionDTO;
910
import io.kafbat.ui.model.rbac.Permission;
11+
import io.kafbat.ui.service.ClustersStorage;
1012
import io.kafbat.ui.service.rbac.AccessControlService;
1113
import java.security.Principal;
1214
import java.util.Collection;
@@ -29,8 +31,15 @@
2931
public class AuthorizationController implements AuthorizationApi {
3032

3133
private final AccessControlService accessControlService;
34+
private final ClustersStorage clustersStorage;
3235

3336
public Mono<ResponseEntity<AuthenticationInfoDTO>> getUserAuthInfo(ServerWebExchange exchange) {
37+
List<UserPermissionDTO> defaultRolePermissions = accessControlService.getDefaultRole() != null
38+
? mapPermissions(
39+
accessControlService.getDefaultRole().getPermissions(),
40+
clustersStorage.getKafkaClusters().stream().map(KafkaCluster::getName).toList())
41+
: Collections.emptyList();
42+
3443
Mono<List<UserPermissionDTO>> permissions = AccessControlService.getUser()
3544
.map(user -> accessControlService.getRoles()
3645
.stream()
@@ -39,6 +48,8 @@ public Mono<ResponseEntity<AuthenticationInfoDTO>> getUserAuthInfo(ServerWebExch
3948
.flatMap(Collection::stream)
4049
.toList()
4150
)
51+
// if no roles are found, return default role permissions
52+
.map(userPermissions -> userPermissions.isEmpty() ? defaultRolePermissions : userPermissions)
4253
.switchIfEmpty(Mono.just(Collections.emptyList()));
4354

4455
Mono<String> userName = ReactiveSecurityContextHolder.getContext()
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.kafbat.ui.model.rbac;
2+
3+
import static com.google.common.base.Preconditions.checkArgument;
4+
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import lombok.Data;
8+
9+
@Data
10+
public class DefaultRole {
11+
12+
private List<Permission> permissions = new ArrayList<>();
13+
14+
public void validate() {
15+
permissions.forEach(Permission::validate);
16+
permissions.forEach(Permission::transform);
17+
}
18+
}

api/src/main/java/io/kafbat/ui/model/rbac/Role.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,4 @@ public void validate() {
2121
permissions.forEach(Permission::transform);
2222
subjects.forEach(Subject::validate);
2323
}
24-
2524
}

api/src/main/java/io/kafbat/ui/service/rbac/AccessControlService.java

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io.kafbat.ui.model.ConnectDTO;
88
import io.kafbat.ui.model.InternalTopic;
99
import io.kafbat.ui.model.rbac.AccessContext;
10+
import io.kafbat.ui.model.rbac.DefaultRole;
1011
import io.kafbat.ui.model.rbac.Permission;
1112
import io.kafbat.ui.model.rbac.Role;
1213
import io.kafbat.ui.model.rbac.Subject;
@@ -62,7 +63,7 @@ public class AccessControlService {
6263

6364
@PostConstruct
6465
public void init() {
65-
if (CollectionUtils.isEmpty(properties.getRoles())) {
66+
if (CollectionUtils.isEmpty(properties.getRoles()) && properties.getDefaultRole() == null) {
6667
log.trace("No roles provided, disabling RBAC");
6768
return;
6869
}
@@ -86,7 +87,8 @@ public void init() {
8687
.flatMap(Set::stream)
8788
.collect(Collectors.toSet());
8889

89-
if (!properties.getRoles().isEmpty()
90+
boolean hasRolesConfigured = !properties.getRoles().isEmpty() || properties.getDefaultRole() != null;
91+
if (hasRolesConfigured
9092
&& "oauth2".equalsIgnoreCase(environment.getProperty("auth.type"))
9193
&& (clientRegistrationRepository == null || !clientRegistrationRepository.iterator().hasNext())) {
9294
log.error("Roles are configured but no authentication methods are present. Authentication might fail.");
@@ -114,12 +116,20 @@ private boolean isAccessible(AuthenticatedUser user, AccessContext context) {
114116
}
115117

116118
private List<Permission> getUserPermissions(AuthenticatedUser user, @Nullable String clusterName) {
117-
return properties.getRoles()
118-
.stream()
119-
.filter(filterRole(user))
120-
.filter(role -> clusterName == null || role.getClusters().stream().anyMatch(clusterName::equalsIgnoreCase))
121-
.flatMap(role -> role.getPermissions().stream())
122-
.toList();
119+
List<Role> filteredRoles = properties.getRoles()
120+
.stream()
121+
.filter(filterRole(user))
122+
.filter(role -> clusterName == null || role.getClusters().stream().anyMatch(clusterName::equalsIgnoreCase))
123+
.toList();
124+
125+
// if no roles are found, check if default role is set
126+
if (filteredRoles.isEmpty() && properties.getDefaultRole() != null) {
127+
return properties.getDefaultRole().getPermissions();
128+
}
129+
130+
return filteredRoles.stream()
131+
.flatMap(role -> role.getPermissions().stream())
132+
.toList();
123133
}
124134

125135
public static Mono<AuthenticatedUser> getUser() {
@@ -132,10 +142,12 @@ public static Mono<AuthenticatedUser> getUser() {
132142

133143
private boolean isClusterAccessible(String clusterName, AuthenticatedUser user) {
134144
Assert.isTrue(StringUtils.isNotEmpty(clusterName), "cluster value is empty");
135-
return properties.getRoles()
145+
boolean isAccessible = properties.getRoles()
136146
.stream()
137147
.filter(filterRole(user))
138148
.anyMatch(role -> role.getClusters().stream().anyMatch(clusterName::equalsIgnoreCase));
149+
150+
return isAccessible || properties.getDefaultRole() != null;
139151
}
140152

141153
public Mono<Boolean> isClusterAccessible(ClusterDTO cluster) {
@@ -200,6 +212,10 @@ public List<Role> getRoles() {
200212
return Collections.unmodifiableList(properties.getRoles());
201213
}
202214

215+
public DefaultRole getDefaultRole() {
216+
return properties.getDefaultRole();
217+
}
218+
203219
private Predicate<Role> filterRole(AuthenticatedUser user) {
204220
return role -> user.groups().contains(role.getName());
205221
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package io.kafbat.ui.service.rbac;
2+
3+
import static io.kafbat.ui.service.rbac.MockedRbacUtils.DEFAULT_ROLE;
4+
import static io.kafbat.ui.service.rbac.MockedRbacUtils.PROD_CLUSTER;
5+
import static io.kafbat.ui.service.rbac.MockedRbacUtils.getAccessContext;
6+
import static org.mockito.Mockito.mock;
7+
import static org.mockito.Mockito.when;
8+
9+
import io.kafbat.ui.AbstractIntegrationTest;
10+
import io.kafbat.ui.config.auth.RbacUser;
11+
import io.kafbat.ui.config.auth.RoleBasedAccessControlProperties;
12+
import io.kafbat.ui.model.ClusterDTO;
13+
import io.kafbat.ui.model.rbac.AccessContext;
14+
import io.kafbat.ui.model.rbac.DefaultRole;
15+
import java.util.List;
16+
import org.junit.jupiter.api.BeforeEach;
17+
import org.junit.jupiter.api.Test;
18+
import org.mockito.Mock;
19+
import org.mockito.MockedStatic;
20+
import org.mockito.Mockito;
21+
import org.springframework.beans.factory.annotation.Autowired;
22+
import org.springframework.security.core.Authentication;
23+
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
24+
import org.springframework.security.core.context.SecurityContext;
25+
import org.springframework.test.annotation.DirtiesContext;
26+
import org.springframework.test.util.ReflectionTestUtils;
27+
import reactor.core.publisher.Mono;
28+
import reactor.test.StepVerifier;
29+
30+
31+
/**
32+
* Test class for AccessControlService with default role and RBAC enabled.
33+
*/
34+
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
35+
public class AccessControlServiceDefaultRoleRbacEnabledTest extends AbstractIntegrationTest {
36+
37+
@Autowired
38+
AccessControlService accessControlService;
39+
40+
@Mock
41+
SecurityContext securityContext;
42+
43+
@Mock
44+
Authentication authentication;
45+
46+
@Mock
47+
RbacUser user;
48+
49+
@Mock
50+
DefaultRole defaultRole;
51+
52+
@BeforeEach
53+
void setUp() {
54+
55+
RoleBasedAccessControlProperties properties = mock();
56+
defaultRole = MockedRbacUtils.getDefaultRole();
57+
when(properties.getDefaultRole()).thenReturn(defaultRole);
58+
when(properties.getRoles()).thenReturn(List.of()); // Return empty list for roles
59+
60+
61+
ReflectionTestUtils.setField(accessControlService, "properties", properties);
62+
ReflectionTestUtils.setField(accessControlService, "rbacEnabled", true);
63+
64+
// Mock security context
65+
when(securityContext.getAuthentication()).thenReturn(authentication);
66+
when(authentication.getPrincipal()).thenReturn(user);
67+
}
68+
69+
public void withSecurityContext(Runnable runnable) {
70+
try (MockedStatic<ReactiveSecurityContextHolder> ctxHolder = Mockito.mockStatic(
71+
ReactiveSecurityContextHolder.class)) {
72+
// Mock static method to get security context
73+
ctxHolder.when(ReactiveSecurityContextHolder::getContext).thenReturn(Mono.just(securityContext));
74+
runnable.run();
75+
}
76+
}
77+
78+
@Test
79+
void validateAccess() {
80+
withSecurityContext(() -> {
81+
when(user.groups()).thenReturn(List.of(DEFAULT_ROLE));
82+
AccessContext context = getAccessContext(PROD_CLUSTER, true);
83+
Mono<Void> validateAccessMono = accessControlService.validateAccess(context);
84+
StepVerifier.create(validateAccessMono)
85+
.expectComplete()
86+
.verify();
87+
});
88+
}
89+
90+
@Test
91+
void isClusterAccessible() {
92+
withSecurityContext(() -> {
93+
ClusterDTO clusterDto = new ClusterDTO();
94+
clusterDto.setName(PROD_CLUSTER);
95+
Mono<Boolean> clusterAccessibleMono = accessControlService.isClusterAccessible(clusterDto);
96+
StepVerifier.create(clusterAccessibleMono)
97+
.expectNext(true)
98+
.expectComplete()
99+
.verify();
100+
});
101+
}
102+
}

api/src/test/java/io/kafbat/ui/service/rbac/MockedRbacUtils.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import static org.mockito.Mockito.when;
66

77
import io.kafbat.ui.model.rbac.AccessContext;
8+
import io.kafbat.ui.model.rbac.DefaultRole;
89
import io.kafbat.ui.model.rbac.Permission;
910
import io.kafbat.ui.model.rbac.Resource;
1011
import io.kafbat.ui.model.rbac.Role;
@@ -20,6 +21,7 @@ public class MockedRbacUtils {
2021

2122
public static final String ADMIN_ROLE = "admin_role";
2223
public static final String DEV_ROLE = "dev_role";
24+
public static final String DEFAULT_ROLE = "default_role";
2325

2426
public static final String PROD_CLUSTER = "prod";
2527
public static final String DEV_CLUSTER = "dev";
@@ -99,6 +101,39 @@ public static Role getDevRole() {
99101
return role;
100102
}
101103

104+
public static DefaultRole getDefaultRole() {
105+
Permission topicViewPermission = new Permission();
106+
topicViewPermission.setResource(Resource.TOPIC.name());
107+
topicViewPermission.setActions(List.of(TopicAction.VIEW.name()));
108+
topicViewPermission.setValue(TOPIC_NAME);
109+
110+
Permission consumerGroupPermission = new Permission();
111+
consumerGroupPermission.setResource(Resource.CONSUMER.name());
112+
consumerGroupPermission.setActions(List.of(ConsumerGroupAction.VIEW.name()));
113+
consumerGroupPermission.setValue(CONSUMER_GROUP_NAME);
114+
115+
Permission schemaPermission = new Permission();
116+
schemaPermission.setResource(Resource.SCHEMA.name());
117+
schemaPermission.setActions(List.of(SchemaAction.VIEW.name()));
118+
schemaPermission.setValue(SCHEMA_NAME);
119+
120+
Permission connectPermission = new Permission();
121+
connectPermission.setResource(Resource.CONNECT.name());
122+
connectPermission.setActions(List.of(ConnectAction.VIEW.name()));
123+
connectPermission.setValue(CONNECT_NAME);
124+
125+
List<Permission> permissions = List.of(
126+
topicViewPermission,
127+
consumerGroupPermission,
128+
schemaPermission,
129+
connectPermission
130+
);
131+
DefaultRole role = new DefaultRole();
132+
role.setPermissions(permissions);
133+
role.validate();
134+
return role;
135+
}
136+
102137
public static AccessContext getAccessContext(String cluster, Boolean resourceAccessible) {
103138
AccessContext.ResourceAccess mockedResource = mock(AccessContext.ResourceAccess.class);
104139
when(mockedResource.isAccessible(any())).thenReturn(resourceAccessible);

contract/src/main/resources/swagger/kafbat-ui-api.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4283,6 +4283,22 @@ components:
42834283
type: array
42844284
items:
42854285
$ref: '#/components/schemas/Action'
4286+
defaultRole:
4287+
type: object
4288+
properties:
4289+
permissions:
4290+
type: array
4291+
items:
4292+
type: object
4293+
properties:
4294+
resource:
4295+
$ref: '#/components/schemas/ResourceType'
4296+
value:
4297+
type: string
4298+
actions:
4299+
type: array
4300+
items:
4301+
$ref: '#/components/schemas/Action'
42864302
webclient:
42874303
type: object
42884304
properties:

0 commit comments

Comments
 (0)