mirror of https://github.com/halo-dev/halo
feat: add role reconciler (#2212)
* feat: add role reconciler * refactor: get role by name * refactor: reconcile * refactor: reconciler * fix: testpull/2217/head
parent
f7945081a5
commit
f62d089237
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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("""
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue