Skip to content

Commit

Permalink
feat: support generating role templates based on OpenAPI docs (#30)
Browse files Browse the repository at this point in the history
### What this PR does?
支持根据 OpenAPI Docs 生成 Role Templates

在 Halo 插件开发中,权限管理是一个关键问题,尤其是配置[角色模板](https://docs.halo.run/developer-guide/plugin/api-reference/server/role-template/#%E8%A7%92%E8%89%B2%E6%A8%A1%E6%9D%BF%E5%AE%9A%E4%B9%89)时,角色的 `rules` 部分往往让开发者感到困惑。具体来说,如何区分资源、apiGroup、verb 等概念是许多开发者的痛点。

`generateRoleTemplates` Task 的出现正是为了简化这一过程,该任务能够根据 [配置 Generate Api Client](https://github.com/halo-sigs/halo-gradle-plugin?tab=readme-ov-file#%E9%85%8D%E7%BD%AE-generateapiclient) 中的配置获取到 OpenAPI docs 的 JSON 文件,并自动生成 Halo 的 Role YAML 文件,让开发者可以专注于自己的业务逻辑,而不是纠结于复杂的角色 `rules` 配置。

```release-note
支持根据 OpenAPI Docs 生成 Role Templates
```
  • Loading branch information
guqing authored Oct 25, 2024
1 parent 4fb07a3 commit d4417da
Show file tree
Hide file tree
Showing 13 changed files with 946 additions and 4 deletions.
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 任务,而后会在日志开头看到类似如下信息:
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/run/halo/gradle/HaloDevtoolsPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package run.halo.gradle.openapi;
package run.halo.gradle.extension;

import java.util.List;
import javax.annotation.Nonnull;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package run.halo.gradle.openapi;
package run.halo.gradle.extension;

import static java.util.Collections.emptyMap;

Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions src/main/java/run/halo/gradle/role/RequestInfo.java
Original file line number Diff line number Diff line change
@@ -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
* <a href="https://github.com/halo-dev/halo/blob/17e9f2be1f63cbfe328d806987a284c702be79fe/application/src/main/java/run/halo/app/security/authorization/RequestInfo.java#L17">Halo RequestInfo</a>
*/
@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[] {});
}
}
166 changes: 166 additions & 0 deletions src/main/java/run/halo/gradle/role/RequestInfoFactory.java
Original file line number Diff line number Diff line change
@@ -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
* <a href="https://github.com/halo-dev/halo/blob/17e9f2be1f63cbfe328d806987a284c702be79fe/application/src/main/java/run/halo/app/security/authorization/RequestInfoFactory.java#L17>Halo RequestInfoFactory</a>
*/
public class RequestInfoFactory {
public static final RequestInfoFactory INSTANCE =
new RequestInfoFactory(Set.of("api", "apis"), Set.of("api"));

/**
* without leading and trailing slashes.
*/
final Set<String> apiPrefixes;

/**
* without leading and trailing slashes.
*/
final Set<String> grouplessApiPrefixes;

/**
* special verbs no subresources.
*/
final Set<String> specialVerbs;

public RequestInfoFactory(Set<String> apiPrefixes, Set<String> grouplessApiPrefixes) {
this(apiPrefixes, grouplessApiPrefixes, Set.of("proxy", "watch"));
}

public RequestInfoFactory(Set<String> apiPrefixes, Set<String> grouplessApiPrefixes,
Set<String> 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<String> 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<String> 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, "/");
}
}
53 changes: 53 additions & 0 deletions src/main/java/run/halo/gradle/role/Role.java
Original file line number Diff line number Diff line change
@@ -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<PolicyRule> 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<String, String> labels = new HashMap<>();
private Map<String, String> annotations = new HashMap<>();
}
}
Loading

0 comments on commit d4417da

Please sign in to comment.