From 55040d6918666d2b94b077f963fa2ed059e8fa42 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Thu, 14 Jul 2022 11:17:09 +0800 Subject: [PATCH] feat: add an API to fetch user permissions (#2240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /area core /milestone 2.0 #### 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: Fixes # #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note None ``` --- .../app/config/ExtensionConfiguration.java | 5 +- .../run/halo/app/core/extension/Role.java | 46 +++--- .../run/halo/app/core/extension/User.java | 2 + .../core/extension/endpoint/UserEndpoint.java | 66 ++++++++- .../reconciler/RoleBindingReconciler.java | 4 +- .../extension/reconciler/RoleReconciler.java | 86 +++++------ .../extension/service/DefaultRoleService.java | 55 +++++++ .../core/extension/service/RoleService.java | 4 + .../core/extension/service/UserService.java | 3 + .../extension/service/UserServiceImpl.java | 16 ++ .../run/halo/app/infra/utils/JsonUtils.java | 33 +++++ .../authorization/DefaultRuleResolver.java | 3 +- .../run/halo/app/core/extension/TestRole.java | 60 ++++++++ .../extension/endpoint/UserEndpointTest.java | 64 +++++++- .../reconciler/RoleBindingReconcilerTest.java | 2 +- .../reconciler/RoleReconcilerTest.java | 140 +++++++++--------- .../service/DefaultRoleServiceTest.java | 84 +++++++++++ .../service/UserServiceImplTest.java | 118 +++++++++++++++ 18 files changed, 631 insertions(+), 160 deletions(-) create mode 100644 src/test/java/run/halo/app/core/extension/TestRole.java create mode 100644 src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java diff --git a/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/src/main/java/run/halo/app/config/ExtensionConfiguration.java index 28e093801..df4bd429b 100644 --- a/src/main/java/run/halo/app/config/ExtensionConfiguration.java +++ b/src/main/java/run/halo/app/config/ExtensionConfiguration.java @@ -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(); } diff --git a/src/main/java/run/halo/app/core/extension/Role.java b/src/main/java/run/halo/app/core/extension/Role.java index be53befd8..dc4346eb3 100644 --- a/src/main/java/run/halo/app/core/extension/Role.java +++ b/src/main/java/run/halo/app/core/extension/Role.java @@ -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 { /** * 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 { diff --git a/src/main/java/run/halo/app/core/extension/User.java b/src/main/java/run/halo/app/core/extension/User.java index d7d78012c..33dd209f0 100644 --- a/src/main/java/run/halo/app/core/extension/User.java +++ b/src/main/java/run/halo/app/core/extension/User.java @@ -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; diff --git a/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java index ae91cca94..be60871c8 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java @@ -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 me(ServerRequest request) { return ReactiveSecurityContextHolder.getContext() .map(ctx -> { @@ -46,6 +58,7 @@ public class UserEndpoint implements CustomEndpoint { .bodyValue(user)); } + @NonNull Mono 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 getUserPermission(ServerRequest request) { + String name = request.pathVariable("name"); + return userService.listRoles(name) + .reduce(new LinkedHashSet(), (list, role) -> { + list.add(role); + return list; + }) + .map(roles -> { + Set 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 mergeUiPermissions(Map annotations) { + Set result = new LinkedHashSet<>(); + String permissionsStr = annotations.get(Role.UI_PERMISSIONS_AGGREGATED_ANNO); + if (StringUtils.isNotBlank(permissionsStr)) { + result.addAll(JsonUtils.jsonToObject(permissionsStr, + new TypeReference>() { + })); + } + String uiPermissionStr = annotations.get(Role.UI_PERMISSIONS_ANNO); + if (StringUtils.isNotBlank(uiPermissionStr)) { + result.addAll(JsonUtils.jsonToObject(uiPermissionStr, + new TypeReference>() { + })); + } + return result; + } + + record UserPermission(Set roles, Set uiPermissions) { + } } diff --git a/src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java index 1b3b9e3e0..dc42586e4 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java @@ -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 diff --git a/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java index 9d4201d55..5642fe6d4 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/RoleReconciler.java @@ -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 annotations = role.getMetadata().getAnnotations(); if (annotations == null) { annotations = new HashMap<>(); role.getMetadata().setAnnotations(annotations); } - Map oldAnnotations = Map.copyOf(annotations); - String s = annotations.get(ROLE_DEPENDENCIES); - List roleDependencies = readValue(s); - List dependencyRules = listDependencyRoles(roleDependencies) - .stream() + Set roleDependencies = readValue(annotations.get(Role.ROLE_DEPENDENCIES_ANNO)); + + List dependenciesRole = roleService.listDependencies(roleDependencies); + + List dependencyRules = dependenciesRole.stream() .map(Role::getRules) .flatMap(List::stream) .sorted() .toList(); + List 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 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 aggregateUiPermissions(List dependencyRoles) { + return dependencyRoles.stream() + .filter(role -> role.getMetadata().getAnnotations() != null) + .map(role -> { + Map roleAnnotations = role.getMetadata().getAnnotations(); + return roleAnnotations.get(Role.UI_PERMISSIONS_ANNO); + }) + .map(this::readValue) + .flatMap(Set::stream) + .sorted() + .toList(); } - private List listDependencyRoles(List dependencies) { - List result = new ArrayList<>(); - if (dependencies == null) { - return result; + private Set readValue(String json) { + if (StringUtils.isBlank(json)) { + return new LinkedHashSet<>(); } - Set visited = new HashSet<>(); - Deque 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 annotations = role.getMetadata().getAnnotations(); - if (annotations != null) { - String roleNameDependencies = annotations.get(ROLE_DEPENDENCIES); - List roleDependencies = readValue(roleNameDependencies); - queue.addAll(roleDependencies); - } - }); - } - return result; + return JsonUtils.jsonToObject(json, new TypeReference<>() { + }); } + } diff --git a/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java b/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java index 4b28a73c3..1f24d6cd1 100644 --- a/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java +++ b/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java @@ -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 listDependencies(Set names) { + List result = new ArrayList<>(); + if (names == null) { + return result; + } + Set visited = new HashSet<>(); + Deque 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 annotations = role.getMetadata().getAnnotations(); + if (annotations != null) { + String roleNameDependencies = annotations.get(Role.ROLE_DEPENDENCIES_ANNO); + List roleDependencies = stringToList(roleNameDependencies); + queue.addAll(roleDependencies); + } + }); + } + return result; + } + + @NonNull + private List stringToList(String str) { + if (StringUtils.isBlank(str)) { + return Collections.emptyList(); + } + return JsonUtils.jsonToObject(str, + new TypeReference<>() { + }); + } } diff --git a/src/main/java/run/halo/app/core/extension/service/RoleService.java b/src/main/java/run/halo/app/core/extension/service/RoleService.java index e477095bc..c8bce3db6 100644 --- a/src/main/java/run/halo/app/core/extension/service/RoleService.java +++ b/src/main/java/run/halo/app/core/extension/service/RoleService.java @@ -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 listRoleRefs(Subject subject); + + List listDependencies(Set names); } diff --git a/src/main/java/run/halo/app/core/extension/service/UserService.java b/src/main/java/run/halo/app/core/extension/service/UserService.java index 149c98ac4..679839581 100644 --- a/src/main/java/run/halo/app/core/extension/service/UserService.java +++ b/src/main/java/run/halo/app/core/extension/service/UserService.java @@ -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 updatePassword(String username, String newPassword); + Flux listRoles(String username); } diff --git a/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java b/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java index 308755a56..ff86bea66 100644 --- a/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java +++ b/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java @@ -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 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)); + } } diff --git a/src/main/java/run/halo/app/infra/utils/JsonUtils.java b/src/main/java/run/halo/app/infra/utils/JsonUtils.java index 9ce464b3d..4377faaf3 100644 --- a/src/main/java/run/halo/app/infra/utils/JsonUtils.java +++ b/src/main/java/run/halo/app/infra/utils/JsonUtils.java @@ -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 real type to convert + * @return converted object + */ + public static T jsonToObject(String json, TypeReference 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 real type to deep copy + * @return deep copy of the source object + */ + @SuppressWarnings("unchecked") + public static T deepCopy(T source) { + try { + return (T) DEFAULT_JSON_MAPPER.readValue(objectToJson(source), source.getClass()); + } catch (JsonProcessingException e) { + throw new JsonParseException(e); + } + } } diff --git a/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java b/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java index 362f7b780..3b7d4a625 100644 --- a/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java +++ b/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java @@ -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 rules = convertFrom(roleDependencyRules); rules.addAll(role.getRules()); return rules; diff --git a/src/test/java/run/halo/app/core/extension/TestRole.java b/src/test/java/run/halo/app/core/extension/TestRole.java new file mode 100644 index 000000000..d5f631f43 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/TestRole.java @@ -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); + } +} diff --git a/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java index 7999c6b58..6bd0df748 100644 --- a/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java @@ -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")); + } } } \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java index 0ccf1ea4c..660850947 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java @@ -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; diff --git a/src/test/java/run/halo/app/core/extension/reconciler/RoleReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/RoleReconcilerTest.java index dc1d84490..9d53c643f 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/RoleReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/RoleReconcilerTest.java @@ -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 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 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 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 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 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 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 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 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\"]"); } } \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java b/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java new file mode 100644 index 000000000..2b2df6dc9 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java @@ -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 manageAnnotations = new HashMap<>(); + manageAnnotations.put(Role.ROLE_DEPENDENCIES_ANNO, "[\"role-template-apple-view\"]"); + roleManage.getMetadata().setAnnotations(manageAnnotations); + + Role roleView = TestRole.getRoleView(); + Map 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 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 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 rolesFromCycle = + roleService.listDependencies(Set.of("role-template-apple-manage")); + assertThat(rolesFromCycle).hasSize(3); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java b/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java index 58b536711..63cb52354 100644 --- a/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java +++ b/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java @@ -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 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)); + } }