feat: add an API to fetch user permissions (#2240)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?
/kind feature
/area core
/milestone 2.0
<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind improvement

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:
新增 `/apis/api.halo.run/v1alpha1/users/{name}/permissions` endpoint 用于根据用户名查询所具有的角色和 ui 权限
1. 当 Role 变更时在 Role reconciler 中查询该 Role 的依赖 Role 聚合其 metadata.annotations 中的 `rbac.authorization.halo.run/ui-permissions` 到当前 Role 的 metadata.annotations 中 key 为`rbac.authorization.halo.run/ui-permissions-aggregated`避免覆盖修改当前 Role 的 `rbac.authorization.halo.run/ui-permissions`
2. 根据用户名查询 ui 权限时,先根据用户名获取 RoleBinding 再获取 Role 然后合并 metadata.annotation 中的两个 key:`rbac.authorization.halo.run/ui-permissions` 和 `rbac.authorization.halo.run/ui-permissions-aggregated` 得到权限作为 API 返回值
#### Which issue(s) this PR fixes:

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/2243/head
guqing 2022-07-14 11:17:09 +08:00 committed by GitHub
parent fe816e6843
commit 55040d6918
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 631 additions and 160 deletions

View File

@ -13,6 +13,7 @@ import run.halo.app.core.extension.reconciler.PluginReconciler;
import run.halo.app.core.extension.reconciler.RoleBindingReconciler;
import run.halo.app.core.extension.reconciler.RoleReconciler;
import run.halo.app.core.extension.reconciler.UserReconciler;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.extension.DefaultExtensionClient;
import run.halo.app.extension.DefaultSchemeManager;
import run.halo.app.extension.DefaultSchemeWatcherManager;
@ -62,9 +63,9 @@ public class ExtensionConfiguration {
}
@Bean
Controller roleController(ExtensionClient client) {
Controller roleController(ExtensionClient client, RoleService roleService) {
return new ControllerBuilder("role-controller", client)
.reconciler(new RoleReconciler(client))
.reconciler(new RoleReconciler(client, roleService))
.extension(new Role())
.build();
}

View File

@ -11,6 +11,7 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import org.springframework.lang.NonNull;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@ -27,6 +28,12 @@ import run.halo.app.extension.GVK;
plural = "roles",
singular = "role")
public class Role extends AbstractExtension {
public static final String ROLE_DEPENDENCY_RULES =
"rbac.authorization.halo.run/dependency-rules";
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";
public static final String UI_PERMISSIONS_AGGREGATED_ANNO =
"rbac.authorization.halo.run/ui-permissions-aggregated";
public static final String GROUP = "";
public static final String VERSION = "v1alpha1";
@ -43,7 +50,7 @@ public class Role extends AbstractExtension {
* @since 2.0.0
*/
@Getter
public static class PolicyRule implements Comparable {
public static class PolicyRule implements Comparable<PolicyRule> {
/**
* APIGroups is the name of the APIGroup that contains the resources.
* If multiple API groups are specified, any action requested against one of the enumerated
@ -104,28 +111,25 @@ public class Role extends AbstractExtension {
}
@Override
public int compareTo(Object o) {
if (o instanceof PolicyRule other) {
int result = compare(apiGroups, other.apiGroups);
if (result != 0) {
return result;
}
result = compare(resources, other.resources);
if (result != 0) {
return result;
}
result = compare(resourceNames, other.resourceNames);
if (result != 0) {
return result;
}
result = compare(nonResourceURLs, other.nonResourceURLs);
if (result != 0) {
return result;
}
result = compare(verbs, other.verbs);
public int compareTo(@NonNull PolicyRule other) {
int result = compare(apiGroups, other.apiGroups);
if (result != 0) {
return result;
}
return 1;
result = compare(resources, other.resources);
if (result != 0) {
return result;
}
result = compare(resourceNames, other.resourceNames);
if (result != 0) {
return result;
}
result = compare(nonResourceURLs, other.nonResourceURLs);
if (result != 0) {
return result;
}
result = compare(verbs, other.verbs);
return result;
}
public static class Builder {

View File

@ -32,6 +32,8 @@ public class User extends AbstractExtension {
public static final String VERSION = "v1alpha1";
public static final String KIND = "User";
public static final String ROLE_NAMES_ANNO = "rbac.authorization.halo.run/role-names";
@Schema(required = true)
private UserSpec spec;

View File

@ -4,12 +4,19 @@ import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import com.fasterxml.jackson.core.type.TypeReference;
import io.micrometer.common.util.StringUtils;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
@ -22,18 +29,23 @@ import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.infra.utils.JsonUtils;
@Component
public class UserEndpoint implements CustomEndpoint {
private final ExtensionClient client;
private final UserService userService;
public UserEndpoint(ExtensionClient client) {
public UserEndpoint(ExtensionClient client, UserService userService) {
this.client = client;
this.userService = userService;
}
@NonNull
Mono<ServerResponse> me(ServerRequest request) {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> {
@ -46,6 +58,7 @@ public class UserEndpoint implements CustomEndpoint {
.bodyValue(user));
}
@NonNull
Mono<ServerResponse> grantPermission(ServerRequest request) {
var username = request.pathVariable("name");
return request.bodyToMono(GrantRequest.class)
@ -115,7 +128,58 @@ public class UserEndpoint implements CustomEndpoint {
.requestBody(
requestBodyBuilder().required(true).implementation(GrantRequest.class))
.response(responseBuilder().implementation(User.class)))
.GET("/users/{name}/permissions", this::getUserPermission,
builder -> builder.operationId("GetPermissions")
.description("Get permissions of user")
.tag(tag)
.parameter(parameterBuilder().in(ParameterIn.PATH).name("name")
.description("User name")
.required(true))
.response(responseBuilder().implementation(Set.class)))
.build();
}
@NonNull
private Mono<ServerResponse> getUserPermission(ServerRequest request) {
String name = request.pathVariable("name");
return userService.listRoles(name)
.reduce(new LinkedHashSet<Role>(), (list, role) -> {
list.add(role);
return list;
})
.map(roles -> {
Set<String> uiPermissions =
roles.stream()
.map(role -> role.getMetadata().getAnnotations())
.filter(Objects::nonNull)
.map(this::mergeUiPermissions)
.flatMap(Set::stream)
.collect(Collectors.toSet());
return new UserPermission(roles, uiPermissions);
})
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result)
);
}
private Set<String> mergeUiPermissions(Map<String, String> annotations) {
Set<String> result = new LinkedHashSet<>();
String permissionsStr = annotations.get(Role.UI_PERMISSIONS_AGGREGATED_ANNO);
if (StringUtils.isNotBlank(permissionsStr)) {
result.addAll(JsonUtils.jsonToObject(permissionsStr,
new TypeReference<LinkedHashSet<String>>() {
}));
}
String uiPermissionStr = annotations.get(Role.UI_PERMISSIONS_ANNO);
if (StringUtils.isNotBlank(uiPermissionStr)) {
result.addAll(JsonUtils.jsonToObject(uiPermissionStr,
new TypeReference<LinkedHashSet<String>>() {
}));
}
return result;
}
record UserPermission(Set<Role> roles, Set<String> uiPermissions) {
}
}

View File

@ -20,8 +20,6 @@ import run.halo.app.infra.utils.JsonUtils;
@Slf4j
public class RoleBindingReconciler implements Reconciler {
static final String ROLE_NAMES_ANNO = "rbac.authorization.halo.run/role-names";
private final ExtensionClient client;
public RoleBindingReconciler(ExtensionClient client) {
@ -57,7 +55,7 @@ public class RoleBindingReconciler implements Reconciler {
annotations = new HashMap<>();
}
var oldAnnotations = Map.copyOf(annotations);
annotations.put(ROLE_NAMES_ANNO, JsonUtils.objectToJson(roleNames));
annotations.put(User.ROLE_NAMES_ANNO, JsonUtils.objectToJson(roleNames));
user.getMetadata().setAnnotations(annotations);
if (!Objects.deepEquals(oldAnnotations, annotations)) {
// update user

View File

@ -1,12 +1,8 @@
package run.halo.app.core.extension.reconciler;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -14,6 +10,7 @@ import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
@ -26,81 +23,66 @@ import run.halo.app.infra.utils.JsonUtils;
*/
@Slf4j
public class RoleReconciler implements Reconciler {
public static final String ROLE_DEPENDENCIES = "halo.run/dependencies";
public static final String ROLE_DEPENDENCY_RULES = "halo.run/dependency-rules";
private final ExtensionClient client;
public RoleReconciler(ExtensionClient client) {
private final RoleService roleService;
public RoleReconciler(ExtensionClient client, RoleService roleService) {
this.client = client;
this.roleService = roleService;
}
@Override
public Result reconcile(Request request) {
client.fetch(Role.class, request.name()).ifPresent(role -> {
final Role oldRole = JsonUtils.deepCopy(role);
Map<String, String> annotations = role.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
role.getMetadata().setAnnotations(annotations);
}
Map<String, String> oldAnnotations = Map.copyOf(annotations);
String s = annotations.get(ROLE_DEPENDENCIES);
List<String> roleDependencies = readValue(s);
List<Role.PolicyRule> dependencyRules = listDependencyRoles(roleDependencies)
.stream()
Set<String> roleDependencies = readValue(annotations.get(Role.ROLE_DEPENDENCIES_ANNO));
List<Role> dependenciesRole = roleService.listDependencies(roleDependencies);
List<Role.PolicyRule> dependencyRules = dependenciesRole.stream()
.map(Role::getRules)
.flatMap(List::stream)
.sorted()
.toList();
List<String> uiPermissions = aggregateUiPermissions(dependenciesRole);
// override dependency rules to annotations
annotations.put(ROLE_DEPENDENCY_RULES, JsonUtils.objectToJson(dependencyRules));
if (!Objects.deepEquals(oldAnnotations, annotations)) {
annotations.put(Role.ROLE_DEPENDENCY_RULES, JsonUtils.objectToJson(dependencyRules));
annotations.put(Role.UI_PERMISSIONS_AGGREGATED_ANNO,
JsonUtils.objectToJson(uiPermissions));
if (!Objects.equals(oldRole, role)) {
client.update(role);
}
});
return new Result(false, null);
}
private List<String> readValue(String json) {
if (StringUtils.isBlank(json)) {
return new ArrayList<>();
}
try {
return JsonUtils.DEFAULT_JSON_MAPPER.readValue(json, new TypeReference<>() {
});
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
private List<String> aggregateUiPermissions(List<Role> dependencyRoles) {
return dependencyRoles.stream()
.filter(role -> role.getMetadata().getAnnotations() != null)
.map(role -> {
Map<String, String> roleAnnotations = role.getMetadata().getAnnotations();
return roleAnnotations.get(Role.UI_PERMISSIONS_ANNO);
})
.map(this::readValue)
.flatMap(Set::stream)
.sorted()
.toList();
}
private List<Role> listDependencyRoles(List<String> dependencies) {
List<Role> result = new ArrayList<>();
if (dependencies == null) {
return result;
private Set<String> readValue(String json) {
if (StringUtils.isBlank(json)) {
return new LinkedHashSet<>();
}
Set<String> visited = new HashSet<>();
Deque<String> queue = new ArrayDeque<>(dependencies);
while (!queue.isEmpty()) {
String roleName = queue.poll();
// detecting cycle in role dependencies
if (visited.contains(roleName)) {
log.warn("Detected a cycle in role dependencies: {},and skipped automatically",
roleName);
continue;
}
visited.add(roleName);
client.fetch(Role.class, roleName).ifPresent(role -> {
result.add(role);
// add role dependencies to queue
Map<String, String> annotations = role.getMetadata().getAnnotations();
if (annotations != null) {
String roleNameDependencies = annotations.get(ROLE_DEPENDENCIES);
List<String> roleDependencies = readValue(roleNameDependencies);
queue.addAll(roleDependencies);
}
});
}
return result;
return JsonUtils.jsonToObject(json, new TypeReference<>() {
});
}
}

View File

@ -1,5 +1,16 @@
package run.halo.app.core.extension.service;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@ -8,11 +19,13 @@ import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.RoleBinding.RoleRef;
import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.infra.utils.JsonUtils;
/**
* @author guqing
* @since 2.0.0
*/
@Slf4j
@Service
public class DefaultRoleService implements RoleService {
@ -35,4 +48,46 @@ public class DefaultRoleService implements RoleService {
null))
.map(RoleBinding::getRoleRef);
}
@Override
@NonNull
public List<Role> listDependencies(Set<String> names) {
List<Role> result = new ArrayList<>();
if (names == null) {
return result;
}
Set<String> visited = new HashSet<>();
Deque<String> queue = new ArrayDeque<>(names);
while (!queue.isEmpty()) {
String roleName = queue.poll();
// detecting cycle in role dependencies
if (visited.contains(roleName)) {
log.warn("Detected a cycle in role dependencies: {},and skipped automatically",
roleName);
continue;
}
visited.add(roleName);
extensionClient.fetch(Role.class, roleName).ifPresent(role -> {
result.add(role);
// add role dependencies to queue
Map<String, String> annotations = role.getMetadata().getAnnotations();
if (annotations != null) {
String roleNameDependencies = annotations.get(Role.ROLE_DEPENDENCIES_ANNO);
List<String> roleDependencies = stringToList(roleNameDependencies);
queue.addAll(roleDependencies);
}
});
}
return result;
}
@NonNull
private List<String> stringToList(String str) {
if (StringUtils.isBlank(str)) {
return Collections.emptyList();
}
return JsonUtils.jsonToObject(str,
new TypeReference<>() {
});
}
}

View File

@ -1,5 +1,7 @@
package run.halo.app.core.extension.service;
import java.util.List;
import java.util.Set;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Flux;
import run.halo.app.core.extension.Role;
@ -16,4 +18,6 @@ public interface RoleService {
Role getRole(String name);
Flux<RoleRef> listRoleRefs(Subject subject);
List<Role> listDependencies(Set<String> names);
}

View File

@ -1,6 +1,8 @@
package run.halo.app.core.extension.service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.User;
public interface UserService {
@ -9,4 +11,5 @@ public interface UserService {
Mono<Void> updatePassword(String username, String newPassword);
Flux<Role> listRoles(String username);
}

View File

@ -1,7 +1,13 @@
package run.halo.app.core.extension.service;
import static run.halo.app.core.extension.RoleBinding.containsUser;
import java.util.Objects;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
@ -28,4 +34,14 @@ public class UserServiceImpl implements UserService {
})
.then();
}
@Override
public Flux<Role> listRoles(String name) {
return Flux.fromStream(client.list(RoleBinding.class, containsUser(name), null)
.stream()
.filter(roleBinding -> Role.KIND.equals(roleBinding.getRoleRef().getKind()))
.map(roleBinding -> roleBinding.getRoleRef().getName())
.map(roleName -> client.fetch(Role.class, roleName).orElse(null))
.filter(Objects::nonNull));
}
}

View File

@ -1,6 +1,7 @@
package run.halo.app.infra.utils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
@ -96,4 +97,36 @@ public class JsonUtils {
throw new JsonParseException(e);
}
}
/**
* Method to deserialize JSON content from given JSON content String.
*
* @param json json content
* @param typeReference type reference to convert
* @param <T> real type to convert
* @return converted object
*/
public static <T> T jsonToObject(String json, TypeReference<T> typeReference) {
try {
return DEFAULT_JSON_MAPPER.readValue(json, typeReference);
} catch (Exception e) {
throw new JsonParseException(e);
}
}
/**
* Method to deserialize JSON content and serialize back from given Object.
*
* @param source source object to copy
* @param <T> real type to deep copy
* @return deep copy of the source object
*/
@SuppressWarnings("unchecked")
public static <T> T deepCopy(T source) {
try {
return (T) DEFAULT_JSON_MAPPER.readValue(objectToJson(source), source.getClass());
} catch (JsonProcessingException e) {
throw new JsonParseException(e);
}
}
}

View File

@ -11,7 +11,6 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.Assert;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.reconciler.RoleReconciler;
import run.halo.app.core.extension.service.DefaultRoleBindingService;
import run.halo.app.core.extension.service.RoleBindingService;
import run.halo.app.core.extension.service.RoleService;
@ -81,7 +80,7 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
}
// merge policy rules
String roleDependencyRules = metadata.getAnnotations()
.get(RoleReconciler.ROLE_DEPENDENCY_RULES);
.get(Role.ROLE_DEPENDENCY_RULES);
List<Role.PolicyRule> rules = convertFrom(roleDependencyRules);
rules.addAll(role.getRules());
return rules;

View File

@ -0,0 +1,60 @@
package run.halo.app.core.extension;
import run.halo.app.infra.utils.JsonUtils;
/**
* Roles to test.
*
* @author guqing
* @since 2.0.0
*/
public class TestRole {
public static Role getRoleManage() {
return JsonUtils.jsonToObject("""
{
"apiVersion": "v1alpha1",
"kind": "Role",
"metadata": {
"name": "role-template-apple-manage"
},
"rules": [{
"resources": ["apples"],
"verbs": ["create"]
}]
}
""", Role.class);
}
public static Role getRoleView() {
return JsonUtils.jsonToObject("""
{
"apiVersion": "v1alpha1",
"kind": "Role",
"metadata": {
"name": "role-template-apple-view"
},
"rules": [{
"resources": ["apples"],
"verbs": ["list"]
}]
}
""", Role.class);
}
public static Role getRoleOther() {
return JsonUtils.jsonToObject("""
{
"apiVersion": "v1alpha1",
"kind": "Role",
"metadata": {
"name": "role-template-apple-other"
},
"rules": [{
"resources": ["apples"],
"verbs": ["update"]
}]
}
""", Role.class);
}
}

View File

@ -25,12 +25,15 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.utils.JsonUtils;
@SpringBootTest
@AutoConfigureWebTestClient
@ -45,6 +48,9 @@ class UserEndpointTest {
@MockBean
ExtensionClient client;
@MockBean
UserService userService;
@BeforeEach
void setUp() {
// disable authorization
@ -165,18 +171,68 @@ class UserEndpointTest {
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UserEndpoint.GrantRequest(Set.of("fake-role")))
.exchange()
.bodyValue(new UserEndpoint.GrantRequest(Set.of("fake-role"))).exchange()
.expectStatus().isOk();
verify(client, times(1)).fetch(same(User.class), eq("fake-user"));
verify(client, times(1)).fetch(same(Role.class), eq("fake-role"));
verify(client, times(1)).create(RoleBinding.create("fake-user", "fake-role"));
verify(client, times(1)).delete(argThat(binding ->
binding.getMetadata().getName().equals(roleBinding.getMetadata().getName())));
verify(client, times(1))
.delete(argThat(binding -> binding.getMetadata().getName()
.equals(roleBinding.getMetadata().getName())));
verify(client, never()).update(isA(RoleBinding.class));
}
@Test
@WithMockUser("fake-user")
void shouldGetPermission() {
Role roleA = JsonUtils.jsonToObject("""
{
"apiVersion": "v1alpha1",
"kind": "Role",
"metadata": {
"name": "test-A",
"annotations": {
"rbac.authorization.halo.run/ui-permissions": "[\\"permission-A\\"]",
"rbac.authorization.halo.run/ui-permissions-aggregated":
"[\\"permission-B\\"]"
}
},
"rules": []
}
""", Role.class);
when(userService.listRoles(eq("fake-user"))).thenReturn(
Flux.fromIterable(List.of(roleA)));
webClient.get().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.json("""
{ "roles": [{
"rules": [],
"apiVersion": "v1alpha1",
"kind": "Role",
"metadata": {
"name": "test-A",
"annotations": {
"rbac.authorization.halo.run/ui-permissions":
"[\\"permission-A\\"]",
"rbac.authorization.halo.run/ui-permissions-aggregated":
"[\\"permission-B\\"]"
}
}
}],
"uiPermissions": [
"permission-A",
"permission-B"
]
}
""");
verify(userService, times(1)).listRoles(eq("fake-user"));
}
}
}

View File

@ -13,7 +13,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.core.extension.reconciler.RoleBindingReconciler.ROLE_NAMES_ANNO;
import static run.halo.app.core.extension.User.ROLE_NAMES_ANNO;
import java.time.Instant;
import java.util.List;

View File

@ -1,12 +1,19 @@
package run.halo.app.core.extension.reconciler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.json.JSONException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -16,9 +23,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.TestRole;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link RoleReconciler}.
@ -32,89 +40,32 @@ class RoleReconcilerTest {
@Mock
private ExtensionClient extensionClient;
private RoleReconciler roleReconciler;
@Mock
private RoleService roleService;
private String roleOther;
private RoleReconciler roleReconciler;
@BeforeEach
void setUp() {
roleReconciler = new RoleReconciler(extensionClient);
Role roleManage = JsonUtils.jsonToObject("""
{
"apiVersion": "v1alpha1",
"kind": "Role",
"metadata": {
"name": "role-template-apple-manage",
"annotations": {
"halo.run/dependencies": "[\\"role-template-apple-view\\"]",
"halo.run/module": "Apple Management",
"halo.run/display-name": "苹果管理"
}
},
"rules": [{
"resources": ["apples"],
"verbs": ["create"]
}]
}
""", Role.class);
Role roleView = JsonUtils.jsonToObject("""
{
"apiVersion": "v1alpha1",
"kind": "Role",
"metadata": {
"name": "role-template-apple-view",
"annotations": {
"halo.run/dependencies": "[\\"role-template-apple-other\\"]"
}
},
"rules": [{
"resources": ["apples"],
"verbs": ["list"]
}]
}
""", Role.class);
roleOther = """
{
"apiVersion": "v1alpha1",
"kind": "Role",
"metadata": {
"name": "role-template-apple-other"
},
"rules": [{
"resources": ["apples"],
"verbs": ["update"]
}]
}
""";
when(extensionClient.fetch(eq(Role.class), eq("role-template-apple-manage"))).thenReturn(
Optional.of(roleManage));
when(extensionClient.fetch(eq(Role.class), eq("role-template-apple-view"))).thenReturn(
Optional.of(roleView));
roleReconciler = new RoleReconciler(extensionClient, roleService);
}
@Test
void reconcile() throws JSONException {
when(extensionClient.fetch(eq(Role.class), eq("role-template-apple-other"))).thenReturn(
Optional.of(JsonUtils.jsonToObject(roleOther, Role.class)));
assertReconcile();
}
Role roleManage = TestRole.getRoleManage();
Map<String, String> manageAnnotations = new HashMap<>();
manageAnnotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-view\"]");
roleManage.getMetadata().setAnnotations(manageAnnotations);
@Test
void detectingCycle() throws JSONException {
Role roleToUse = JsonUtils.jsonToObject(roleOther, Role.class);
// build a cycle in the dependency
Map<String, String> annotations =
Map.of("halo.run/dependencies", "[\"role-template-apple-view\"]");
roleToUse.getMetadata().setAnnotations(annotations);
when(extensionClient.fetch(eq(Role.class), eq("role-template-apple-other"))).thenReturn(
Optional.of(roleToUse));
Role roleView = TestRole.getRoleView();
assertReconcile();
}
Role roleOther = TestRole.getRoleOther();
when(roleService.listDependencies(eq(Set.of("role-template-apple-view"))))
.thenReturn(List.of(roleView, roleOther));
when(extensionClient.fetch(eq(Role.class), eq("role-template-apple-manage"))).thenReturn(
Optional.of(roleManage));
private void assertReconcile() throws JSONException {
ArgumentCaptor<Role> roleCaptor = ArgumentCaptor.forClass(Role.class);
doNothing().when(extensionClient).update(roleCaptor.capture());
@ -135,6 +86,47 @@ class RoleReconcilerTest {
Role updateArgs = roleCaptor.getValue();
assertThat(updateArgs).isNotNull();
JSONAssert.assertEquals(expected, updateArgs.getMetadata().getAnnotations()
.get(RoleReconciler.ROLE_DEPENDENCY_RULES), false);
.get(Role.ROLE_DEPENDENCY_RULES), false);
}
@Test
void reconcileUiPermission() {
Role roleManage = TestRole.getRoleManage();
Map<String, String> annotations = new LinkedHashMap<>();
annotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-view\"]");
annotations.put(Role.UI_PERMISSIONS_ANNO, "[\"apples:manage\"]");
roleManage.getMetadata().setAnnotations(annotations);
Role roleView = TestRole.getRoleView();
Map<String, String> roleViewAnnotations = new LinkedHashMap<>();
roleViewAnnotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-other\"]");
roleViewAnnotations.put(Role.UI_PERMISSIONS_ANNO, "[\"apples:view\"]");
roleView.getMetadata().setAnnotations(roleViewAnnotations);
Role roleOther = TestRole.getRoleOther();
Map<String, String> roleOtherAnnotations = new LinkedHashMap<>();
roleOtherAnnotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-other\"]");
roleOtherAnnotations.put(Role.UI_PERMISSIONS_ANNO, "[\"apples:foo\", \"apples:bar\"]");
roleOther.getMetadata().setAnnotations(roleOtherAnnotations);
when(extensionClient.fetch(eq(Role.class), eq("role-template-apple-manage"))).thenReturn(
Optional.of(roleManage));
when(roleService.listDependencies(any()))
.thenReturn(List.of(roleView, roleOther));
ArgumentCaptor<Role> roleCaptor = ArgumentCaptor.forClass(Role.class);
roleReconciler.reconcile(new Reconciler.Request("role-template-apple-manage"));
verify(extensionClient, times(1)).update(roleCaptor.capture());
// assert that the user has the correct roles
Role value = roleCaptor.getValue();
Map<String, String> resultAnnotations = value.getMetadata().getAnnotations();
assertThat(resultAnnotations).isNotNull();
assertThat(resultAnnotations.containsKey(Role.UI_PERMISSIONS_AGGREGATED_ANNO)).isTrue();
assertThat(resultAnnotations.get(Role.UI_PERMISSIONS_AGGREGATED_ANNO)).isEqualTo(
"[\"apples:bar\",\"apples:foo\",\"apples:view\"]");
}
}

View File

@ -0,0 +1,84 @@
package run.halo.app.core.extension.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.TestRole;
import run.halo.app.extension.ExtensionClient;
/**
* Tests for {@link DefaultRoleService}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class DefaultRoleServiceTest {
@Mock
private ExtensionClient extensionClient;
private DefaultRoleService roleService;
@BeforeEach
void setUp() {
roleService = new DefaultRoleService(extensionClient);
}
@Test
void listDependencie() {
Role roleManage = TestRole.getRoleManage();
Map<String, String> manageAnnotations = new HashMap<>();
manageAnnotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-view\"]");
roleManage.getMetadata().setAnnotations(manageAnnotations);
Role roleView = TestRole.getRoleView();
Map<String, String> viewAnnotations = new HashMap<>();
viewAnnotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-other\"]");
roleView.getMetadata().setAnnotations(viewAnnotations);
Role roleOther = TestRole.getRoleOther();
when(extensionClient.fetch(same(Role.class), eq("role-template-apple-manage")))
.thenReturn(Optional.of(roleManage));
when(extensionClient.fetch(same(Role.class), eq("role-template-apple-view")))
.thenReturn(Optional.of(roleView));
when(extensionClient.fetch(same(Role.class), eq("role-template-apple-other")))
.thenReturn(Optional.of(roleOther));
// list without cycle
List<Role> roles = roleService.listDependencies(Set.of("role-template-apple-manage"));
verify(extensionClient, times(1)).fetch(same(Role.class), eq("role-template-apple-manage"));
verify(extensionClient, times(1)).fetch(same(Role.class), eq("role-template-apple-view"));
verify(extensionClient, times(1)).fetch(same(Role.class), eq("role-template-apple-other"));
assertThat(roles).hasSize(3);
assertThat(roles).containsExactly(roleManage, roleView, roleOther);
// list dependencies with a cycle relation
Map<String, String> anotherAnnotations = new HashMap<>();
anotherAnnotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-view\"]");
roleOther.getMetadata().setAnnotations(anotherAnnotations);
when(extensionClient.fetch(same(Role.class), eq("role-template-apple-other")))
.thenReturn(Optional.of(roleOther));
// correct behavior is to ignore the cycle relation
List<Role> rolesFromCycle =
roleService.listDependencies(Set.of("role-template-apple-manage"));
assertThat(rolesFromCycle).hasSize(3);
}
}

View File

@ -4,10 +4,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -15,8 +17,12 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.utils.JsonUtils;
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@ -76,4 +82,116 @@ class UserServiceImplTest {
verify(client, times(1)).fetch(eq(User.class), eq("faker"));
verify(client, times(0)).update(any());
}
@Test
void shouldListRolesIfUserFoundInExtension() {
User fakeUser = new User();
Metadata metadata = new Metadata();
metadata.setName("faker");
fakeUser.setMetadata(metadata);
fakeUser.setSpec(new User.UserSpec());
when(client.list(eq(RoleBinding.class), any(), any())).thenReturn(getRoleBindings());
Role roleA = new Role();
Metadata metadataA = new Metadata();
metadataA.setName("test-A");
roleA.setMetadata(metadataA);
Role roleB = new Role();
Metadata metadataB = new Metadata();
metadataB.setName("test-B");
roleB.setMetadata(metadataB);
Role roleC = new Role();
Metadata metadataC = new Metadata();
metadataC.setName("ddd");
roleC.setMetadata(metadataC);
when(client.fetch(eq(Role.class), eq("test-A"))).thenReturn(Optional.of(roleA));
when(client.fetch(eq(Role.class), eq("test-B"))).thenReturn(Optional.of(roleB));
lenient().when(client.fetch(eq(Role.class), eq("ddd"))).thenReturn(Optional.of(roleC));
StepVerifier.create(userService.listRoles("faker"))
.expectNext(roleA)
.expectNext(roleB)
.verifyComplete();
verify(client, times(1)).list(eq(RoleBinding.class), any(), any());
verify(client, times(1)).fetch(eq(Role.class), eq("test-A"));
verify(client, times(1)).fetch(eq(Role.class), eq("test-B"));
verify(client, times(0)).fetch(eq(Role.class), eq("ddd"));
}
List<RoleBinding> getRoleBindings() {
String bindA = """
{
"apiVersion": "v1alpha1",
"kind": "RoleBinding",
"metadata": {
"name": "bind-A"
},
"subjects": [{
"kind": "User",
"name": "faker",
"apiGroup": ""
}],
"roleRef": {
"kind": "Role",
"name": "test-A",
"apiGroup": ""
}
}
""";
String bindB = """
{
"apiVersion": "v1alpha1",
"kind": "RoleBinding",
"metadata": {
"name": "bind-B"
},
"subjects": [{
"kind": "User",
"name": "faker",
"apiGroup": ""
},
{
"kind": "User",
"name": "zhangsan",
"apiGroup": ""
}],
"roleRef": {
"kind": "Role",
"name": "test-B",
"apiGroup": ""
}
}
""";
String bindC = """
{
"apiVersion": "v1alpha1",
"kind": "RoleBinding",
"metadata": {
"name": "bind-C"
},
"subjects": [{
"kind": "User",
"name": "faker",
"apiGroup": ""
}],
"roleRef": {
"kind": "Fake",
"name": "ddd",
"apiGroup": ""
}
}
""";
return List.of(JsonUtils.jsonToObject(bindA, RoleBinding.class),
JsonUtils.jsonToObject(bindB, RoleBinding.class),
JsonUtils.jsonToObject(bindC, RoleBinding.class));
}
}