feat: add role reconciler (#2212)

* feat: add role reconciler

* refactor: get role by name

* refactor: reconcile

* refactor: reconciler

* fix: test
pull/2217/head
guqing 2022-07-06 16:40:12 +08:00 committed by GitHub
parent f7945081a5
commit f62d089237
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 331 additions and 17 deletions

View File

@ -5,7 +5,9 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.reconciler.RoleReconciler;
import run.halo.app.core.extension.reconciler.UserReconciler;
import run.halo.app.extension.DefaultExtensionClient;
import run.halo.app.extension.DefaultSchemeManager;
@ -52,4 +54,12 @@ public class ExtensionConfiguration {
.extension(new User())
.build();
}
@Bean
Controller roleController(ExtensionClient client) {
return new ControllerBuilder("role-controller", client)
.reconciler(new RoleReconciler(client))
.extension(new Role())
.build();
}
}

View File

@ -1,5 +1,7 @@
package run.halo.app.core.extension;
import static java.util.Arrays.compare;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import lombok.Data;
@ -23,7 +25,7 @@ import run.halo.app.extension.GVK;
singular = "role")
public class Role extends AbstractExtension {
@Schema(minLength = 1)
@Schema(required = true)
List<PolicyRule> rules;
/**
@ -34,7 +36,7 @@ public class Role extends AbstractExtension {
* @since 2.0.0
*/
@Getter
public static class PolicyRule {
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
@ -94,6 +96,31 @@ public class Role extends AbstractExtension {
return items;
}
@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);
return result;
}
return 1;
}
public static class Builder {
String[] apiGroups;

View File

@ -0,0 +1,106 @@
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.List;
import java.util.Map;
import java.util.Objects;
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.extension.ExtensionClient;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
/**
* Role reconcile.
*
* @author guqing
* @since 2.0.0
*/
@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) {
this.client = client;
}
@Override
public Result reconcile(Request request) {
client.fetch(Role.class, request.name()).ifPresent(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()
.map(Role::getRules)
.flatMap(List::stream)
.sorted()
.toList();
// override dependency rules to annotations
annotations.put(ROLE_DEPENDENCY_RULES, JsonUtils.objectToJson(dependencyRules));
if (!Objects.deepEquals(oldAnnotations, annotations)) {
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<Role> listDependencyRoles(List<String> dependencies) {
List<Role> result = new ArrayList<>();
if (dependencies == null) {
return result;
}
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;
}
}

View File

@ -70,14 +70,15 @@ public class JsonUtils {
*
* @param source source object must not be null
* @return json format of the source object
* @throws JsonProcessingException throws when fail to convert
*/
@NonNull
public static String objectToJson(@NonNull Object source)
throws JsonProcessingException {
public static String objectToJson(@NonNull Object source) {
Assert.notNull(source, "Source object must not be null");
return DEFAULT_JSON_MAPPER.writeValueAsString(source);
try {
return DEFAULT_JSON_MAPPER.writeValueAsString(source);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
/**

View File

@ -1,15 +1,22 @@
package run.halo.app.security.authorization;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import lombok.Data;
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;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.infra.utils.JsonUtils;
/**
* @author guqing
@ -49,7 +56,8 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
for (String roleName : roleNames) {
try {
Role role = roleService.getRole(roleName);
rules = role.getRules();
// fetch rules from role
rules = fetchRules(role);
} catch (Exception e) {
if (visitor.visit(null, null, e)) {
return;
@ -65,6 +73,31 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
}
}
private List<Role.PolicyRule> fetchRules(Role role) {
MetadataOperator metadata = role.getMetadata();
if (metadata == null || metadata.getAnnotations() == null) {
return role.getRules();
}
// merge policy rules
String roleDependencyRules = metadata.getAnnotations()
.get(RoleReconciler.ROLE_DEPENDENCY_RULES);
List<Role.PolicyRule> rules = convertFrom(roleDependencyRules);
rules.addAll(role.getRules());
return rules;
}
private List<Role.PolicyRule> convertFrom(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);
}
}
String roleBindingDescriber(String roleName, String subject) {
return String.format("Binding role [%s] to [%s]", roleName, subject);
}

View File

@ -0,0 +1,140 @@
package run.halo.app.core.extension.reconciler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.Optional;
import org.json.JSONException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
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.extension.ExtensionClient;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link RoleReconciler}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class RoleReconcilerTest {
@Mock
private ExtensionClient extensionClient;
private RoleReconciler roleReconciler;
private String roleOther;
@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));
}
@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();
}
@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));
assertReconcile();
}
private void assertReconcile() throws JSONException {
ArgumentCaptor<Role> roleCaptor = ArgumentCaptor.forClass(Role.class);
doNothing().when(extensionClient).update(roleCaptor.capture());
Reconciler.Request request = new Reconciler.Request("role-template-apple-manage");
roleReconciler.reconcile(request);
String expected = """
[
{
"resources": ["apples"],
"verbs": ["list"]
},
{
"resources": ["apples"],
"verbs": ["update"]
}
]
""";
Role updateArgs = roleCaptor.getValue();
assertThat(updateArgs).isNotNull();
JSONAssert.assertEquals(expected, updateArgs.getMetadata().getAnnotations()
.get(RoleReconciler.ROLE_DEPENDENCY_RULES), false);
}
}

View File

@ -2,7 +2,6 @@ package run.halo.app.infra.utils;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
@ -17,7 +16,7 @@ import org.junit.jupiter.api.Test;
public class JsonUtilsTest {
@Test
public void serializerTime() throws JsonProcessingException {
public void serializerTime() {
Instant now = Instant.now();
String instantStr = JsonUtils.objectToJson(now);
assertThat(instantStr).isNotNull();

View File

@ -2,7 +2,6 @@ package run.halo.app.infra.utils;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
@ -63,7 +62,7 @@ class YamlUnstructuredLoaderTest {
}
@Test
void loadTest() throws JsonProcessingException {
void loadTest() {
Resource[] resources = yamlResources.toArray(Resource[]::new);
YamlUnstructuredLoader yamlUnstructuredLoader = new YamlUnstructuredLoader(resources);
List<Unstructured> unstructuredList = yamlUnstructuredLoader.load();

View File

@ -2,7 +2,6 @@ package run.halo.app.plugin;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
@ -60,7 +59,7 @@ class YamlPluginDescriptorFinderTest {
}
@Test
void find() throws JsonProcessingException, JSONException {
void find() throws JSONException {
PluginDescriptor pluginDescriptor = yamlPluginDescriptorFinder.find(testFile.toPath());
String actual = JsonUtils.objectToJson(pluginDescriptor);
JSONAssert.assertEquals("""

View File

@ -123,7 +123,7 @@ class YamlPluginFinderTest {
}
@Test
void acceptArrayLicense() throws JSONException, JsonProcessingException {
void acceptArrayLicense() throws JSONException {
Resource pluginResource = new InMemoryResource("""
apiVersion: v1
kind: Plugin
@ -143,7 +143,7 @@ class YamlPluginFinderTest {
}
@Test
void acceptMultipleItemArrayLicense() throws JsonProcessingException, JSONException {
void acceptMultipleItemArrayLicense() throws JSONException {
Resource pluginResource = new InMemoryResource("""
apiVersion: v1
kind: Plugin
@ -167,7 +167,7 @@ class YamlPluginFinderTest {
}
@Test
void acceptArrayObjectLicense() throws JSONException, JsonProcessingException {
void acceptArrayObjectLicense() throws JSONException {
Resource pluginResource = new InMemoryResource("""
apiVersion: v1
kind: Plugin