feat: support aggregate several roles into one combined role (#3568)

#### What type of PR is this?
/kind feature
/milestone 2.4.x
/area core

#### What this PR does / why we need it:
支持聚合多个角色到一个角色

see #3560 for more details.

how to test it?
创建一个测试角色和和一个 RoleBinding 将此角色的绑定到其他角色,在不修改用户权限的情况下,用户将拥有新创建的测试角色的权限。

#### Which issue(s) this PR fixes:

Fixes #3560

#### Does this PR introduce a user-facing change?

```release-note
支持聚合多个角色到一个角色
```
pull/3604/head v2.4.0-rc.1
guqing 2023-03-27 17:48:01 +08:00 committed by GitHub
parent d194c34848
commit bd4cc0c72d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 176 additions and 4 deletions

View File

@ -30,6 +30,8 @@ import run.halo.app.extension.GVK;
public class Role extends AbstractExtension {
public static final String ROLE_DEPENDENCY_RULES =
"rbac.authorization.halo.run/dependency-rules";
public static final String ROLE_AGGREGATE_LABEL_PREFIX =
"rbac.authorization.halo.run/aggregate-to-";
public static final String ROLE_DEPENDENCIES_ANNO = "rbac.authorization.halo.run/dependencies";
public static final String UI_PERMISSIONS_ANNO = "rbac.authorization.halo.run/ui-permissions";

View File

@ -1,14 +1,18 @@
package run.halo.app.core.extension.service;
import static run.halo.app.extension.MetadataUtil.nullSafeLabels;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
@ -110,7 +114,38 @@ public class DefaultRoleService implements RoleService {
return Flux.fromIterable(dependencies)
.filter(dependency -> !visited.contains(dependency))
.flatMap(dependencyName -> extensionClient.fetch(Role.class, dependencyName));
});
})
.flatMap(role -> Flux.just(role)
.mergeWith(listAggregatedRoles(role.getMetadata().getName()))
);
}
Flux<Role> listAggregatedRoles(String roleName) {
return extensionClient.list(Role.class,
role -> Boolean.parseBoolean(nullSafeLabels(role)
.get(Role.ROLE_AGGREGATE_LABEL_PREFIX + roleName)
),
Comparator.comparing(item -> item.getMetadata().getCreationTimestamp()));
}
Predicate<RoleBinding> getRoleBindingPredicate(Subject targetSubject) {
return roleBinding -> {
List<Subject> subjects = roleBinding.getSubjects();
for (Subject subject : subjects) {
return matchSubject(targetSubject, subject);
}
return false;
};
}
private static boolean matchSubject(Subject targetSubject, Subject subject) {
if (targetSubject == null || subject == null) {
return false;
}
return StringUtils.equals(targetSubject.getKind(), subject.getKind())
&& StringUtils.equals(targetSubject.getName(), subject.getName())
&& StringUtils.defaultString(targetSubject.getApiGroup())
.equals(StringUtils.defaultString(subject.getApiGroup()));
}
@Override

View File

@ -109,14 +109,39 @@ public class RbacRequestEvaluation {
if (ArrayUtils.isEmpty(rule.getResourceNames())) {
return true;
}
String[] requestedNameParts = ArrayUtils.nullToEmpty(StringUtils.split(requestedName, "/"));
for (String ruleName : rule.getResourceNames()) {
if (Objects.equals(ruleName, requestedName)) {
return true;
String[] patternParts = StringUtils.split(ruleName, "/");
for (int i = 0; i < patternParts.length; i++) {
String patternPart = patternParts[i];
String textPart = StringUtils.EMPTY;
if (requestedNameParts.length > i) {
textPart = requestedNameParts[i];
}
if (!matchPart(patternPart, textPart)) {
return false;
}
}
return true;
}
return false;
}
private static boolean matchPart(String patternPart, String textPart) {
if (patternPart.equals("*")) {
return true;
} else if (patternPart.startsWith("*")) {
return textPart.endsWith(patternPart.substring(1));
} else if (patternPart.endsWith("*")) {
return textPart.startsWith(patternPart.substring(0, patternPart.length() - 1));
} else {
return patternPart.equals(textPart);
}
}
protected boolean nonResourceURLMatches(Role.PolicyRule rule, String requestedURL) {
for (String ruleURL : rule.getNonResourceURLs()) {
if (Objects.equals(ruleURL, WildCard.NonResourceAll)) {

View File

@ -1,6 +1,7 @@
package run.halo.app.core.extension.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.eq;
@ -13,6 +14,9 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.assertj.core.api.AssertionsForInterfaceTypes;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@ -23,6 +27,7 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.TestRole;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
@ -104,6 +109,8 @@ class DefaultRoleServiceTest {
when(extensionClient.fetch(Role.class, "role1")).thenReturn(Mono.just(role1));
when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2));
when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3));
when(extensionClient.list(eq(Role.class), any(), any()))
.thenReturn(Flux.empty());
// call the method under test
Flux<Role> result = roleService.listDependenciesFlux(roleNames);
@ -132,6 +139,8 @@ class DefaultRoleServiceTest {
when(extensionClient.fetch(Role.class, "role1")).thenReturn(Mono.just(role1));
when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2));
when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3));
when(extensionClient.list(eq(Role.class), any(), any()))
.thenReturn(Flux.empty());
// call the method under test
Flux<Role> result = roleService.listDependenciesFlux(roleNames);
@ -164,6 +173,8 @@ class DefaultRoleServiceTest {
when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2));
when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3));
when(extensionClient.fetch(Role.class, "role4")).thenReturn(Mono.just(role4));
when(extensionClient.list(eq(Role.class), any(), any()))
.thenReturn(Flux.empty());
// call the method under test
Flux<Role> result = roleService.listDependenciesFlux(roleNames);
@ -197,6 +208,8 @@ class DefaultRoleServiceTest {
when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2));
when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3));
when(extensionClient.fetch(Role.class, "role4")).thenReturn(Mono.just(role4));
when(extensionClient.list(eq(Role.class), any(), any()))
.thenReturn(Flux.empty());
// call the method under test
Flux<Role> result = roleService.listDependenciesFlux(roleNames);
@ -230,6 +243,8 @@ class DefaultRoleServiceTest {
when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2));
when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3));
lenient().when(extensionClient.fetch(Role.class, "role4")).thenReturn(Mono.just(role4));
when(extensionClient.list(eq(Role.class), any(), any()))
.thenReturn(Flux.empty());
// call the method under test
Flux<Role> result = roleService.listDependenciesFlux(roleNames);
@ -272,6 +287,8 @@ class DefaultRoleServiceTest {
when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2));
when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.empty());
when(extensionClient.fetch(Role.class, "role4")).thenReturn(Mono.just(role4));
when(extensionClient.list(eq(Role.class), any(), any()))
.thenReturn(Flux.empty());
Flux<Role> result = roleService.listDependenciesFlux(roleNames);
// verify the result
@ -285,6 +302,52 @@ class DefaultRoleServiceTest {
verify(extensionClient, times(4)).fetch(eq(Role.class), anyString());
}
@Test
void testSubjectMatch() {
RoleBinding fakeAuthenticatedBinding =
createRoleBinding("authenticated-fake-binding", "fake", "authenticated");
RoleBinding fakeEditorBinding =
createRoleBinding("editor-fake-binding", "fake", "editor");
RoleBinding fakeAnonymousBinding =
createRoleBinding("test-anonymous-binding", "test", "anonymous");
RoleBinding.Subject subject = new RoleBinding.Subject();
subject.setName("authenticated");
subject.setKind(Role.KIND);
subject.setApiGroup(Role.GROUP);
Predicate<RoleBinding> predicate = roleService.getRoleBindingPredicate(subject);
List<RoleBinding> result =
Stream.of(fakeAuthenticatedBinding, fakeEditorBinding, fakeAnonymousBinding)
.filter(predicate)
.toList();
AssertionsForInterfaceTypes.assertThat(result)
.containsExactly(fakeAuthenticatedBinding);
subject.setName("editor");
predicate = roleService.getRoleBindingPredicate(subject);
result =
Stream.of(fakeAuthenticatedBinding, fakeEditorBinding, fakeAnonymousBinding)
.filter(predicate)
.toList();
AssertionsForInterfaceTypes.assertThat(result).containsExactly(fakeEditorBinding);
}
RoleBinding createRoleBinding(String name, String refName, String subjectName) {
RoleBinding roleBinding = new RoleBinding();
roleBinding.setMetadata(new Metadata());
roleBinding.getMetadata().setName(name);
roleBinding.setRoleRef(new RoleBinding.RoleRef());
roleBinding.getRoleRef().setKind(Role.KIND);
roleBinding.getRoleRef().setApiGroup(Role.GROUP);
roleBinding.getRoleRef().setName(refName);
roleBinding.setSubjects(List.of(new RoleBinding.Subject()));
roleBinding.getSubjects().get(0).setKind(Role.KIND);
roleBinding.getSubjects().get(0).setName(subjectName);
roleBinding.getSubjects().get(0).setApiGroup(Role.GROUP);
return roleBinding;
}
private Role createRole(String name, String... dependencies) {
Role role = new Role();
role.setMetadata(new Metadata());
@ -296,4 +359,4 @@ class DefaultRoleServiceTest {
return role;
}
}
}
}

View File

@ -0,0 +1,47 @@
package run.halo.app.security.authorization;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import org.junit.jupiter.api.Test;
import run.halo.app.core.extension.Role;
/**
* Tests for {@link RbacRequestEvaluation}.
*
* @author guqing
* @since 2.4.0
*/
class RbacRequestEvaluationTest {
@Test
void resourceNameMatches() {
RbacRequestEvaluation rbacRequestEvaluation = new RbacRequestEvaluation();
assertThat(matchResourceName(rbacRequestEvaluation, "", "fake/test")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "", "fake")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "", "")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "*", null)).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "*/test", "fake/test")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "*/test", "hello/test")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "*/test", "hello/fake")).isFalse();
assertThat(matchResourceName(rbacRequestEvaluation, "test/*", "hello/fake")).isFalse();
assertThat(matchResourceName(rbacRequestEvaluation, "test/*", "test/fake")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "test/*", "test")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "test/*", "hello")).isFalse();
assertThat(matchResourceName(rbacRequestEvaluation, "*/*", "test/fake")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "*/*", "test")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "*", "test")).isTrue();
assertThat(matchResourceName(rbacRequestEvaluation, "*", "hello")).isTrue();
}
boolean matchResourceName(RbacRequestEvaluation rbacRequestEvaluation, String rule,
String requestedName) {
return rbacRequestEvaluation.resourceNameMatches(new Role.PolicyRule.Builder()
.resourceNames(rule)
.build(), requestedName);
}
}