diff --git a/README.md b/README.md index 63b1405..61f4a2e 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,43 @@ const { data } = await momentsConsoleApiClient.moment.listTags({ > [!WARNING] > 执行 `generateApiClient` 任务时会先删除 `openApi.generator.outputDir` 下的所有文件,因此建议将 API client 的输出目录设置为一个独立的目录,以避免误删其他文件。 +### generateRoleTemplates 任务 + +在 Halo 插件开发中,权限管理是一个关键问题,尤其是配置[角色模板](https://docs.halo.run/developer-guide/plugin/security/rbac#%E8%A7%92%E8%89%B2%E6%A8%A1%E6%9D%BF)时,角色的 `rules` 部分往往让开发者感到困惑。具体来说,如何区分资源、apiGroup、verb 等概念是许多开发者的痛点。 + +`generateRoleTemplates` Task 的出现正是为了简化这一过程,该任务能够根据 [配置 Generate Api Client](#配置-generateapiclient) 中的配置获取到 OpenAPI docs 的 JSON 文件,并自动生成 Halo 的 Role YAML 文件,让开发者可以专注于自己的业务逻辑,而不是纠结于复杂的角色 `rules` 配置。 + +在生成的 `roleTemplate.yaml` 文件中,rules 部分是基于 OpenAPI docs 中 API 资源和请求方式自动生成的,覆盖了可能的操作。 +然而,在实际的生产环境中,Role 通常会根据具体的需求被划分为不同的权限级别,例如: + +- 查看权限的角色模板:通常只包含对资源的读取权限,如 get、list、watch 等。 +- 管理权限的角色模板:则可能包含创建、修改、删除等权限,如 create、update、delete。 + +> watch verb 是对于 WebSocket API,不会在 roleTemplates.yaml 中体现为 watch,而是体现为 list,因此需要开发者根据实际情况进行调整。 + +因此,生成的 YAML 文件只是一个基础模板,涵盖了所有可用的操作。开发者需要根据自己的实际需求,对这些 rules 进行调整。比如,针对只需要查看资源的场景,开发者可以从生成的 YAML 中删除`修改`和`删除`相关的操作,保留读取权限。 +而对于需要管理资源的场景,可以保留`创建`、`更新`和`删除`权限,对于角色模板的依赖关系和聚合关系,开发者也可以根据实际情况进行调整。 + +通过这种方式,开发者可以使用生成的 YAML 文件作为基础,快速定制出符合不同场景的权限配置,而不必从头开始编写复杂的规则以减少出错的可能性。 + +#### 如何使用 + +在 build.gradle 文件中,使用 haloPlugin 块来配置 OpenAPI 文档生成和 Role 模板生成的相关设置: + +```groovy +haloPlugin { + openApi { + // 参考配置 generateApiClient 中的配置 + } +} +``` + +在项目目录中执行以下命令即可生成 `roleTemplates.yaml` 文件到 `workplace` 目录: + +```shell +./gradlew generateRoleTemplates +``` + ## Debug 如果你想要调试 Halo 插件项目,可以使用 IntelliJ IDEA 的 Debug 模式运行 `haloServer` 或 `watch` 任务,而后会在日志开头看到类似如下信息: diff --git a/src/main/java/run/halo/gradle/HaloDevtoolsPlugin.java b/src/main/java/run/halo/gradle/HaloDevtoolsPlugin.java index dedeb2c..a588ecf 100644 --- a/src/main/java/run/halo/gradle/HaloDevtoolsPlugin.java +++ b/src/main/java/run/halo/gradle/HaloDevtoolsPlugin.java @@ -43,6 +43,7 @@ import run.halo.gradle.openapi.ApiClientGeneratorTask; import run.halo.gradle.openapi.CleanupApiServerContainer; import run.halo.gradle.openapi.OpenApiDocsGeneratorTask; +import run.halo.gradle.role.RoleTemplateGenerateTask; import run.halo.gradle.utils.YamlUtils; import run.halo.gradle.watch.WatchTask; @@ -201,6 +202,13 @@ public void apply(Project project) { it.dependsOn("generateOpenApiDocs"); }); + project.getTasks() + .create("generateRoleTemplates", RoleTemplateGenerateTask.class, it -> { + it.setGroup(GROUP); + it.setDescription("Generate role templates from open api spec."); + it.dependsOn("generateOpenApiDocs"); + }); + project.getTasks().withType(AbstractDockerRemoteApiTask.class) .configureEach(task -> task.getDockerClientService().set(serviceProvider)); diff --git a/src/main/java/run/halo/gradle/openapi/GroupedOpenApiExtension.java b/src/main/java/run/halo/gradle/extension/GroupedOpenApiExtension.java similarity index 96% rename from src/main/java/run/halo/gradle/openapi/GroupedOpenApiExtension.java rename to src/main/java/run/halo/gradle/extension/GroupedOpenApiExtension.java index e0bf82b..95c585f 100644 --- a/src/main/java/run/halo/gradle/openapi/GroupedOpenApiExtension.java +++ b/src/main/java/run/halo/gradle/extension/GroupedOpenApiExtension.java @@ -1,4 +1,4 @@ -package run.halo.gradle.openapi; +package run.halo.gradle.extension; import java.util.List; import javax.annotation.Nonnull; diff --git a/src/main/java/run/halo/gradle/extension/HaloPluginExtension.java b/src/main/java/run/halo/gradle/extension/HaloPluginExtension.java index 6055af9..c1a0402 100644 --- a/src/main/java/run/halo/gradle/extension/HaloPluginExtension.java +++ b/src/main/java/run/halo/gradle/extension/HaloPluginExtension.java @@ -8,7 +8,6 @@ import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.Property; -import run.halo.gradle.openapi.OpenApiExtension; import run.halo.gradle.watch.WatchTarget; @Data diff --git a/src/main/java/run/halo/gradle/openapi/OpenApiExtension.java b/src/main/java/run/halo/gradle/extension/OpenApiExtension.java similarity index 98% rename from src/main/java/run/halo/gradle/openapi/OpenApiExtension.java rename to src/main/java/run/halo/gradle/extension/OpenApiExtension.java index 2175502..aed2ada 100644 --- a/src/main/java/run/halo/gradle/openapi/OpenApiExtension.java +++ b/src/main/java/run/halo/gradle/extension/OpenApiExtension.java @@ -1,4 +1,4 @@ -package run.halo.gradle.openapi; +package run.halo.gradle.extension; import static java.util.Collections.emptyMap; @@ -20,7 +20,7 @@ import org.gradle.api.plugins.ExtensionContainer; import org.gradle.api.provider.MapProperty; import org.gradle.api.provider.Property; -import run.halo.gradle.extension.HaloExtension; +import run.halo.gradle.openapi.ApiClientExtension; @Data @ToString diff --git a/src/main/java/run/halo/gradle/role/RequestInfo.java b/src/main/java/run/halo/gradle/role/RequestInfo.java new file mode 100644 index 0000000..f5dadb5 --- /dev/null +++ b/src/main/java/run/halo/gradle/role/RequestInfo.java @@ -0,0 +1,58 @@ +package run.halo.gradle.role; + +import java.util.Objects; +import lombok.Getter; +import lombok.ToString; +import org.apache.commons.lang3.StringUtils; + +/** + * @see + * Halo RequestInfo + */ +@Getter +@ToString +public class RequestInfo { + boolean isResourceRequest; + final String path; + String namespace; + String userspace; + String verb; + String apiPrefix; + String apiGroup; + String apiVersion; + String resource; + + String name; + + String subresource; + + String subName; + + String[] parts; + + public RequestInfo(boolean isResourceRequest, String path, String verb) { + this(isResourceRequest, path, null, null, verb, null, null, null, null, null, null, null, + null); + } + + public RequestInfo(boolean isResourceRequest, String path, String namespace, String userspace, + String verb, + String apiPrefix, + String apiGroup, + String apiVersion, String resource, String name, String subresource, String subName, + String[] parts) { + this.isResourceRequest = isResourceRequest; + this.path = StringUtils.defaultString(path); + this.namespace = StringUtils.defaultString(namespace); + this.userspace = StringUtils.defaultString(userspace); + this.verb = StringUtils.defaultString(verb); + this.apiPrefix = StringUtils.defaultString(apiPrefix); + this.apiGroup = StringUtils.defaultString(apiGroup); + this.apiVersion = StringUtils.defaultString(apiVersion); + this.resource = StringUtils.defaultString(resource); + this.subresource = StringUtils.defaultString(subresource); + this.subName = StringUtils.defaultString(subName); + this.name = StringUtils.defaultString(name); + this.parts = Objects.requireNonNullElseGet(parts, () -> new String[] {}); + } +} diff --git a/src/main/java/run/halo/gradle/role/RequestInfoFactory.java b/src/main/java/run/halo/gradle/role/RequestInfoFactory.java new file mode 100644 index 0000000..26a25ef --- /dev/null +++ b/src/main/java/run/halo/gradle/role/RequestInfoFactory.java @@ -0,0 +1,166 @@ +package run.halo.gradle.role; + +import java.util.Arrays; +import java.util.Objects; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; + +/** + * @see + * apiPrefixes; + + /** + * without leading and trailing slashes. + */ + final Set grouplessApiPrefixes; + + /** + * special verbs no subresources. + */ + final Set specialVerbs; + + public RequestInfoFactory(Set apiPrefixes, Set grouplessApiPrefixes) { + this(apiPrefixes, grouplessApiPrefixes, Set.of("proxy", "watch")); + } + + public RequestInfoFactory(Set apiPrefixes, Set grouplessApiPrefixes, + Set specialVerbs) { + this.apiPrefixes = apiPrefixes; + this.grouplessApiPrefixes = grouplessApiPrefixes; + this.specialVerbs = specialVerbs; + } + + public RequestInfo newRequestInfo(String requestPath, String requestMethod) { + // non-resource request default + RequestInfo requestInfo = + new RequestInfo(false, requestPath, requestMethod.toLowerCase()); + + String[] currentParts = splitPath(requestPath); + + if (currentParts.length < 3) { + // return a non-resource request + return requestInfo; + } + + if (!apiPrefixes.contains(currentParts[0])) { + // return a non-resource request + return requestInfo; + } + requestInfo.apiPrefix = currentParts[0]; + currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); + + if (!grouplessApiPrefixes.contains(requestInfo.apiPrefix)) { + // one part (APIPrefix) has already been consumed, so this is actually "do we have + // four parts?" + if (currentParts.length < 3) { + // return a non-resource request + return requestInfo; + } + + requestInfo.apiGroup = StringUtils.defaultString(currentParts[0]); + currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); + } + requestInfo.isResourceRequest = true; + requestInfo.apiVersion = currentParts[0]; + currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); + // handle input of form /{specialVerb}/* + Set specialVerbs = Set.of("proxy", "watch"); + if (specialVerbs.contains(currentParts[0])) { + if (currentParts.length < 2) { + throw new IllegalArgumentException( + String.format("unable to determine kind and namespace from url, %s", + requestPath)); + } + requestInfo.verb = currentParts[0]; + currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); + } else { + requestInfo.verb = switch (requestMethod.toUpperCase()) { + case "POST" -> "create"; + case "GET", "HEAD" -> "get"; + case "PUT" -> "update"; + case "PATCH" -> "patch"; + case "DELETE" -> "delete"; + default -> ""; + }; + } + // URL forms: /namespaces/{namespace}/{kind}/*, where parts are adjusted to be relative + // to kind + Set namespaceSubresources = Set.of("status", "finalize"); + if (Objects.equals(currentParts[0], "namespaces")) { + if (currentParts.length > 1) { + requestInfo.namespace = currentParts[1]; + + // if there is another step after the namespace name and it is not a known + // namespace subresource + // move currentParts to include it as a resource in its own right + if (currentParts.length > 2 && !namespaceSubresources.contains(currentParts[2])) { + currentParts = Arrays.copyOfRange(currentParts, 2, currentParts.length); + } + } + } else if ("userspaces".equals(currentParts[0])) { + if (currentParts.length > 1) { + requestInfo.userspace = currentParts[1]; + + // if there is another step after the userspace name + // move currentParts to include it as a resource in its own right + if (currentParts.length > 2) { + currentParts = Arrays.copyOfRange(currentParts, 2, currentParts.length); + } + } + } else { + requestInfo.userspace = ""; + requestInfo.namespace = ""; + } + + // parsing successful, so we now know the proper value for .Parts + requestInfo.parts = currentParts; + // special verbs no subresources + // parts look like: resource/resourceName/subresource/other/stuff/we/don't/interpret + if (requestInfo.parts.length >= 3 && !specialVerbs.contains( + requestInfo.verb)) { + requestInfo.subresource = requestInfo.parts[2]; + // if there is another step after the subresource name, and it is not a known + if (requestInfo.parts.length >= 4) { + requestInfo.subName = requestInfo.parts[3]; + } + } + + if (requestInfo.parts.length >= 2) { + requestInfo.name = requestInfo.parts[1]; + } + + if (requestInfo.parts.length >= 1) { + requestInfo.resource = requestInfo.parts[0]; + } + + // has name and no subresource but verb=create, then this is a non-resource request + if (StringUtils.isNotBlank(requestInfo.name) && StringUtils.isBlank(requestInfo.subresource) + && "create".equals(requestInfo.verb)) { + requestInfo.isResourceRequest = false; + } + + // if there's no name on the request, and we thought it was a get before, then the actual + // verb is a list or a watch + if (requestInfo.name.isEmpty() && "get".equals(requestInfo.verb)) { + requestInfo.verb = "list"; + } + return requestInfo; + } + + private String[] splitPath(String path) { + path = StringUtils.strip(path, "/"); + if (StringUtils.isEmpty(path)) { + return new String[] {}; + } + return StringUtils.split(path, "/"); + } +} diff --git a/src/main/java/run/halo/gradle/role/Role.java b/src/main/java/run/halo/gradle/role/Role.java new file mode 100644 index 0000000..55d4746 --- /dev/null +++ b/src/main/java/run/halo/gradle/role/Role.java @@ -0,0 +1,53 @@ +package run.halo.gradle.role; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +public class Role { + private final String kind = "Role"; + + private final String apiVersion = "v1alpha1"; + + private final Metadata metadata = new Metadata(); + + private final List rules = new ArrayList<>(); + + @Data + @NoArgsConstructor + public static class PolicyRule { + private String[] apiGroups; + + private String[] resources; + + private String[] resourceNames; + + private String[] nonResourceURLs; + + private String[] verbs; + + @Builder + public PolicyRule(String[] apiGroups, String[] resources, String[] resourceNames, + String[] nonResourceURLs, String[] verbs) { + this.apiGroups = apiGroups; + this.resources = resources; + this.resourceNames = resourceNames; + this.nonResourceURLs = nonResourceURLs; + this.verbs = verbs; + } + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Metadata { + private String name; + private Map labels = new HashMap<>(); + private Map annotations = new HashMap<>(); + } +} diff --git a/src/main/java/run/halo/gradle/role/RoleTemplateGenerateTask.java b/src/main/java/run/halo/gradle/role/RoleTemplateGenerateTask.java new file mode 100644 index 0000000..213f40c --- /dev/null +++ b/src/main/java/run/halo/gradle/role/RoleTemplateGenerateTask.java @@ -0,0 +1,109 @@ +package run.halo.gradle.role; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.File; +import java.io.IOException; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; +import javax.inject.Inject; +import lombok.Getter; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.Directory; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.model.ObjectFactory; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; +import run.halo.gradle.extension.HaloPluginExtension; + +@Getter +public class RoleTemplateGenerateTask extends DefaultTask { + + @Internal + final ListProperty schemaJsonFiles; + + @InputFile + final RegularFileProperty outputFile; + + @Inject + public RoleTemplateGenerateTask(ObjectFactory objects) { + this.schemaJsonFiles = objects.listProperty(File.class); + this.outputFile = objects.fileProperty(); + + var pluginExtension = getProject().getExtensions().getByType(HaloPluginExtension.class); + var openApi = pluginExtension.getOpenApi(); + var schemaOutputDir = openApi.getOutputDir(); + Provider> schemaJsonFileProvider = openApi.getGroupedApiMappings() + .map(fileMapping -> fileMapping.values() + .stream() + .map(schemaOutputDir::dir) + .map(Provider::getOrNull) + .filter(Objects::nonNull) + .map(Directory::getAsFile) + .toList() + ); + this.schemaJsonFiles.set(schemaJsonFileProvider); + + var resultFile = pluginExtension.getWorkDir().file("roleTemplates.yaml") + .map(file -> { + var filePath = file.getAsFile().toPath(); + if (Files.notExists(filePath)) { + try { + Files.createFile(filePath); + } catch (IOException e) { + throw new UncheckedIOException("Error creating file: " + filePath, e); + } + } + return file; + }); + this.outputFile.convention(resultFile); + } + + @TaskAction + public void generate() { + var roles = new RoleTemplateGenerator(schemaJsonFiles.get()).createRoles(); + var yaml = writeListAsString(roles); + + // Write to file + try { + Files.writeString(outputFile.get().getAsFile().toPath(), yaml); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + static String writeListAsString(List roles) { + StringWriter writer = new StringWriter(); + try { + RoleYamlWriter.mapper.writer().writeValues(writer).writeAll(roles); + return writer.toString(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + static class RoleYamlWriter { + public static final ObjectMapper mapper; + + static { + YAMLFactory yamlFactory = new YAMLFactory() + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + .enable(YAMLGenerator.Feature.SPLIT_LINES) + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + .enable(YAMLGenerator.Feature.INDENT_ARRAYS_WITH_INDICATOR); + + mapper = new ObjectMapper(yamlFactory); + mapper.registerModule(new JavaTimeModule()); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + } +} diff --git a/src/main/java/run/halo/gradle/role/RoleTemplateGenerator.java b/src/main/java/run/halo/gradle/role/RoleTemplateGenerator.java new file mode 100644 index 0000000..596ddf1 --- /dev/null +++ b/src/main/java/run/halo/gradle/role/RoleTemplateGenerator.java @@ -0,0 +1,254 @@ +package run.halo.gradle.role; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import lombok.Builder; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import run.halo.gradle.utils.JsonUtils; + +@Slf4j +public class RoleTemplateGenerator { + private final List schemaJsonFiles; + + public RoleTemplateGenerator(List schemaJsonFiles) { + this.schemaJsonFiles = schemaJsonFiles; + } + + public List createRoles() { + var apiResources = parseApiResources(); + return createRoles(apiResources); + } + + private List createRoles(List apiResources) { + var apiGroupResourceMap = new TreeMap>(); + var nonResourceMap = new TreeMap>(); + + // categorize api resources + apiResources.forEach(apiResource -> { + if (apiResource.isResourceRequest()) { + var resourceRequest = (ResourceRequest) apiResource; + apiGroupResourceMap.computeIfAbsent(resourceRequest.apiGroup(), + k -> new ArrayList<>()) + .add(resourceRequest); + } else { + var noneResourceRequest = (NoneResourceRequest) apiResource; + nonResourceMap.computeIfAbsent(noneResourceRequest.resourceUrl(), + k -> new TreeSet<>()) + .add(noneResourceRequest.verb()); + } + }); + + // generate rules + var roles = new ArrayList(); + + // for resource requests + apiGroupResourceMap.forEach((apiGroup, resourceRequests) -> { + var nameRequestsMap = resourceRequests.stream() + .collect(Collectors.groupingBy(ResourceRequest::name)); + + var role = createRole(buildResourceRoleName(apiGroup)); + + nameRequestsMap.forEach((name, requests) -> { + var builder = Role.PolicyRule.builder() + .apiGroups(new String[] {apiGroup}); + if (StringUtils.isNotBlank(name)) { + builder.resourceNames(new String[] {name}); + } + + // Collect resources and verbs once to avoid multiple stream operations + var resourcesAndVerbs = requests.stream().collect(Collectors.teeing( + Collectors.mapping(request -> { + var resourceUrl = request.resource(); + if (StringUtils.isNotBlank(request.subResource())) { + resourceUrl += "/" + request.subResource(); + } + return resourceUrl; + }, Collectors.toSet()), + Collectors.mapping(ResourceRequest::verb, Collectors.toSet()), + ResourcesVerbs::new + )); + + builder.resources(resourcesAndVerbs.resources().toArray(new String[0])); + builder.verbs(resourcesAndVerbs.verbs().toArray(new String[0])); + + role.getRules().add(builder.build()); + }); + + roles.add(role); + }); + + // for non-resource requests + nonResourceMap.forEach((url, verbs) -> { + var role = createRole(buildNonResourceRoleName()); + var builder = Role.PolicyRule.builder() + .nonResourceURLs(new String[] {url}) + .verbs(verbs.toArray(new String[0])); + role.getRules().add(builder.build()); + roles.add(role); + }); + + return roles; + } + + record ResourcesVerbs(Set resources, Set verbs) { + } + + @NonNull + private static Role createRole(String roleName) { + var role = new Role(); + var metadata = role.getMetadata(); + metadata.getLabels().put("halo.run/role-template", "true"); + metadata.getAnnotations().put("rbac.authorization.halo.run/module", "{所属模块}"); + metadata.getAnnotations() + .put("rbac.authorization.halo.run/display-name", "{角色显示名称}"); + metadata.getAnnotations() + .put("rbac.authorization.halo.run/ui-permissions", "['{定义 UI 权限}']"); + metadata.setName(roleName); + return role; + } + + private static String buildResourceRoleName(String apiGroup) { + return "rt-" + apiGroup + "-" + RandomStringUtils.randomAlphabetic(6); + } + + private static String buildNonResourceRoleName() { + return "rt-" + RandomStringUtils.randomAlphabetic(10); + } + + private List parseApiResources() { + var requests = parseRequestsFromSchemaFiles(); + var apiResources = new ArrayList(); + requests.forEach(request -> { + var requestInfo = parseRequestInfo(request); + if (requestInfo.isResourceRequest()) { + apiResources.add(ResourceRequest.builder() + .apiGroup(requestInfo.getApiGroup()) + .resource(requestInfo.getResource()) + .name(requestInfo.getName()) + .subResource(requestInfo.getSubresource()) + .verb(requestInfo.getVerb()) + .build()); + } else { + apiResources.add(NoneResourceRequest.builder() + .resourceUrl(requestInfo.getPath()) + .verb(requestInfo.getVerb()) + .build()); + } + }); + return apiResources; + } + + private RequestInfo parseRequestInfo(SimpleRequest simpleRequest) { + return RequestInfoFactory.INSTANCE + .newRequestInfo(simpleRequest.path(), simpleRequest.method()); + } + + private List parseRequestsFromSchemaFiles() { + var requests = new ArrayList(); + schemaJsonFiles.forEach(file -> { + requests.addAll(parseRequestsFromSchemaFile(file)); + }); + return requests; + } + + /** + * Example JSON file content: + *
+     * {
+     *   "paths": {
+     *     "/apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config": {
+     *       "get": {},
+     *       "post": {},
+     *     }
+     *   }
+     * }
+     * 
+ */ + private List parseRequestsFromSchemaFile(File file) { + var node = readFileToJson(file); + if (node == null || !node.has("paths")) { + return List.of(); + } + var pathsNode = node.get("paths"); + var requests = new ArrayList(); + pathsNode.fields().forEachRemaining(pathNode -> { + var requestPath = pathNode.getKey(); + var methodsNode = pathNode.getValue(); + if (methodsNode == null) { + return; + } + methodsNode.fieldNames().forEachRemaining(requestMethod -> { + requests.add(SimpleRequest.builder() + .path(requestPath) + .method(requestMethod) + .build()); + }); + }); + return requests; + } + + @Builder + record SimpleRequest(String path, String method) { + } + + interface ApiResource { + boolean isResourceRequest(); + } + + @Builder + record ResourceRequest(String apiGroup, String resource, String name, String subResource, + String verb) implements ApiResource, Comparator { + + @Override + public boolean isResourceRequest() { + return true; + } + + @Override + public int compare(ResourceRequest o1, ResourceRequest o2) { + return Comparator.comparing(ResourceRequest::apiGroup) + .thenComparing(ResourceRequest::resource) + .compare(o1, o2); + } + } + + @Builder + record NoneResourceRequest(String resourceUrl, String verb) + implements ApiResource { + @Override + public boolean isResourceRequest() { + return false; + } + } + + @Nullable + ObjectNode readFileToJson(File file) { + if (!file.exists()) { + return null; + } + try { + JsonNode jsonNode = JsonUtils.mapper().readTree(file); + if (jsonNode.isObject()) { + return (ObjectNode) jsonNode; + } + return null; + } catch (IOException e) { + // ignore + log.warn("Failed to read JSON file: {}", file.getAbsolutePath()); + } + return null; + } +} diff --git a/src/main/java/run/halo/gradle/utils/JsonUtils.java b/src/main/java/run/halo/gradle/utils/JsonUtils.java new file mode 100644 index 0000000..0d12cf7 --- /dev/null +++ b/src/main/java/run/halo/gradle/utils/JsonUtils.java @@ -0,0 +1,20 @@ +package run.halo.gradle.utils; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +public class JsonUtils { + + private static final ObjectMapper mapper; + + static { + mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + public static ObjectMapper mapper() { + return mapper; + } +} diff --git a/src/test/java/run/halo/gradle/role/RoleTemplateGenerateTaskTest.java b/src/test/java/run/halo/gradle/role/RoleTemplateGenerateTaskTest.java new file mode 100644 index 0000000..07c4619 --- /dev/null +++ b/src/test/java/run/halo/gradle/role/RoleTemplateGenerateTaskTest.java @@ -0,0 +1,61 @@ +package run.halo.gradle.role; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link RoleTemplateGenerateTask}. + * + * @author guqing + * @since 0.3.0 + */ +class RoleTemplateGenerateTaskTest { + + @Test + void writeListAsStringTest() { + var role1 = new Role(); + role1.getRules().add(Role.PolicyRule.builder() + .apiGroups(new String[] {"api.console.doc.halo.run"}) + .resources(new String[] {"docs"}) + .verbs(new String[] {"create"}) + .build()); + var role2 = new Role(); + role2.getRules().add(Role.PolicyRule.builder() + .apiGroups(new String[] {"api.console.content.halo.run"}) + .resources(new String[] {"posts"}) + .verbs(new String[] {"get", "list"}) + .build()); + var result = RoleTemplateGenerateTask.writeListAsString(List.of(role1, role2)); + assertThat(result).isEqualToIgnoringNewLines(""" + --- + kind: Role + apiVersion: v1alpha1 + metadata: + labels: {} + annotations: {} + rules: + - apiGroups: + - api.console.doc.halo.run + resources: + - docs + verbs: + - create + --- + kind: Role + apiVersion: v1alpha1 + metadata: + labels: {} + annotations: {} + rules: + - apiGroups: + - api.console.content.halo.run + resources: + - posts + verbs: + - get + - list + """); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/gradle/role/RoleTemplateGeneratorTest.java b/src/test/java/run/halo/gradle/role/RoleTemplateGeneratorTest.java new file mode 100644 index 0000000..b8233d9 --- /dev/null +++ b/src/test/java/run/halo/gradle/role/RoleTemplateGeneratorTest.java @@ -0,0 +1,177 @@ +package run.halo.gradle.role; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.skyscreamer.jsonassert.JSONAssert; +import run.halo.gradle.utils.JsonUtils; + +/** + * Tests for {@link RoleTemplateGenerator}. + * + * @author guqing + * @since 0.3.0 + */ +class RoleTemplateGeneratorTest { + + @Test + void createRoles(@TempDir Path tempDir) throws JsonProcessingException, JSONException { + var schemaJsonFile = tempDir.resolve("schema.json"); + var schemaJson = fakeSchemaJson(); + writeToFile(schemaJsonFile, schemaJson); + + var roleTemplateGenerator = new RoleTemplateGenerator(List.of(schemaJsonFile.toFile())); + var roles = roleTemplateGenerator.createRoles() + .stream() + .peek(role -> role.getMetadata().setName("a-name")) + .toList(); + + JSONAssert.assertEquals(""" + [ + { + "kind": "Role", + "apiVersion": "v1alpha1", + "metadata": { + "name": "a-name", + "labels": { + "halo.run/role-template": "true" + }, + "annotations": { + "rbac.authorization.halo.run/ui-permissions": "['{定义 UI 权限}']", + "rbac.authorization.halo.run/display-name": "{角色显示名称}", + "rbac.authorization.halo.run/module": "{所属模块}" + } + }, + "rules": [ + { + "apiGroups": ["api.console.halo.run"], + "resources": ["attachments"], + "verbs": ["list"] + }, + { + "apiGroups": ["api.console.halo.run"], + "resources": ["attachments", "attachments/download"], + "resourceNames": ["{name}"], + "verbs": ["get"] + } + ] + }, + { + "kind": "Role", + "apiVersion": "v1alpha1", + "metadata": { + "name": "a-name", + "labels": { + "halo.run/role-template": "true" + }, + "annotations": { + "rbac.authorization.halo.run/ui-permissions": "['{定义 UI 权限}']", + "rbac.authorization.halo.run/display-name": "{角色显示名称}", + "rbac.authorization.halo.run/module": "{所属模块}" + } + }, + "rules": [ + { + "apiGroups": ["api.content.halo.run"], + "resources": ["categories", "posts"], + "verbs": ["create", "list"] + }, + { + "apiGroups": ["api.content.halo.run"], + "resources": ["categories", "categories/posts"], + "resourceNames": ["{name}"], + "verbs": ["get"] + } + ] + }, + { + "kind": "Role", + "apiVersion": "v1alpha1", + "metadata": { + "name": "a-name", + "labels": { + "halo.run/role-template": "true" + }, + "annotations": { + "rbac.authorization.halo.run/ui-permissions": "['{定义 UI 权限}']", + "rbac.authorization.halo.run/display-name": "{角色显示名称}", + "rbac.authorization.halo.run/module": "{所属模块}" + } + }, + "rules": [{ + "nonResourceURLs": ["/actuator/info"], + "verbs": ["get"] + }] + }, + { + "kind": "Role", + "apiVersion": "v1alpha1", + "metadata": { + "name": "a-name", + "labels": { + "halo.run/role-template": "true" + }, + "annotations": { + "rbac.authorization.halo.run/ui-permissions": "['{定义 UI 权限}']", + "rbac.authorization.halo.run/display-name": "{角色显示名称}", + "rbac.authorization.halo.run/module": "{所属模块}" + } + }, + "rules": [{ + "nonResourceURLs": ["/health"], + "verbs": ["get"] + }] + } + ] + """, JsonUtils.mapper().writeValueAsString(roles), true); + } + + private void writeToFile(Path path, String content) { + try { + Files.writeString(path, content); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + String fakeSchemaJson() { + return """ + { + "paths": { + "/apis/api.console.halo.run/v1alpha1/attachments": { + "get": {} + }, + "/apis/api.console.halo.run/v1alpha1/attachments/{name}": { + "get": {} + }, + "/apis/api.console.halo.run/v1alpha1/attachments/{name}/download": { + "get": {} + }, + "/apis/api.content.halo.run/v1alpha1/categories": { + "get": {} + }, + "/apis/api.content.halo.run/v1alpha1/categories/{name}": { + "get": {} + }, + "/apis/api.content.halo.run/v1alpha1/categories/{name}/posts": { + "get": {} + }, + "/apis/api.content.halo.run/v1alpha1/posts": { + "post": {} + }, + "/health": { + "get": {} + }, + "/actuator/info": { + "get": {} + } + } + } + """; + } +}