feat: add some APIs to separate theme-related Setting and ConfigMap permissions (#3135)

#### What type of PR is this?
/kind feature
/milestone 2.2.x
/area core
/kind api-change

#### What this PR does / why we need it:
添加自定义 APIs 以分离主题对 Setting 和 ConfigMap 权限的依赖

⚠️ 此 PR 新增了 APIs,需要 Console 对其进行适配,主题菜单下获取激活主题的 API 以及查询主题 Setting、ConfigMap 的 APIs 需要更改。

#### Which issue(s) this PR fixes:

A part of #3069

Fixes https://github.com/halo-dev/halo/issues/3069

#### Special notes for your reviewer:
how to test it?
- 测试 `GET /themes/-/activation` 此 endpoint 是否能正确获取到激活主题信息
- 测试 `GET /themes/{name}/setting` 是否能正确获取到主题名称对应的主题的 Setting,如果 name 为 `-` 表示查询当前激活主题的 Setting
- 测试 `GET /themes/{name}/config` 是否能正确获取到主题名称对应的主题的 ConfigMap, 如果 name 为 `-` 表示查询当前激活主题的 ConfigMap。
- 测试 `PUT themes/{name}/config` 是否能更新主题的 ConfigMap,如果名称不匹配则抛出异常。
切换用户为其分配主题的查看权限可以有权限调用以上描述的 GET 请求的 endpoints,管理权限可以调用 `PUT themes/{name}/config`。
- 测试 `PUT /themes/{name}/activation` 是否能设置激活主题。

/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
添加自定义 APIs 以分离主题对 Setting 和 ConfigMap 权限的依赖
```
pull/3182/head^2
guqing 2023-01-30 14:26:12 +08:00 committed by GitHub
parent 536218d702
commit 7f4b3a1330
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 313 additions and 13 deletions

View File

@ -11,7 +11,6 @@ import java.util.UUID;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.retry.support.RetryTemplate; import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.FileSystemUtils; import org.springframework.util.FileSystemUtils;
import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.AnnotationSetting;
import run.halo.app.core.extension.Setting; import run.halo.app.core.extension.Setting;
@ -150,9 +149,7 @@ public class ThemeReconciler implements Reconciler<Request> {
client.fetch(Setting.class, theme.getSpec().getSettingName()) client.fetch(Setting.class, theme.getSpec().getSettingName())
.ifPresent(setting -> { .ifPresent(setting -> {
var data = SettingUtils.settingDefinedDefaultValueMap(setting); var data = SettingUtils.settingDefinedDefaultValueMap(setting);
if (CollectionUtils.isEmpty(data)) { // Whether there is a default value or not
return;
}
ConfigMap configMap = new ConfigMap(); ConfigMap configMap = new ConfigMap();
configMap.setMetadata(new Metadata()); configMap.setMetadata(new Metadata());
configMap.getMetadata().setName(configMapNameToUse); configMap.getMetadata().setName(configMapNameToUse);

View File

@ -14,14 +14,19 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.Duration;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part; import org.springframework.http.codec.multipart.Part;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.BodyExtractors;
import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunction;
@ -30,6 +35,8 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.ServerWebInputException;
import reactor.core.Exceptions; import reactor.core.Exceptions;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.Theme;
import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ConfigMap;
@ -37,7 +44,11 @@ import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.QueryParamBuildUtil; import run.halo.app.extension.router.QueryParamBuildUtil;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.ThemeRootGetter;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.TemplateEngineManager; import run.halo.app.theme.TemplateEngineManager;
/** /**
@ -48,6 +59,7 @@ import run.halo.app.theme.TemplateEngineManager;
*/ */
@Slf4j @Slf4j
@Component @Component
@AllArgsConstructor
public class ThemeEndpoint implements CustomEndpoint { public class ThemeEndpoint implements CustomEndpoint {
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
@ -58,13 +70,7 @@ public class ThemeEndpoint implements CustomEndpoint {
private final TemplateEngineManager templateEngineManager; private final TemplateEngineManager templateEngineManager;
public ThemeEndpoint(ReactiveExtensionClient client, ThemeRootGetter themeRoot, private final SystemConfigurableEnvironmentFetcher systemEnvironmentFetcher;
ThemeService themeService, TemplateEngineManager templateEngineManager) {
this.client = client;
this.themeRoot = themeRoot;
this.themeService = themeService;
this.templateEngineManager = templateEngineManager;
}
@Override @Override
public RouterFunction<ServerResponse> endpoint() { public RouterFunction<ServerResponse> endpoint() {
@ -119,6 +125,36 @@ public class ThemeEndpoint implements CustomEndpoint {
.response(responseBuilder() .response(responseBuilder()
.implementation(ConfigMap.class)) .implementation(ConfigMap.class))
) )
.PUT("themes/{name}/config", this::updateThemeConfig,
builder -> builder.operationId("updateThemeConfig")
.description("Update the configMap of theme setting.")
.tag(tag)
.parameter(parameterBuilder()
.name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder().mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(schemaBuilder().implementation(ConfigMap.class))))
.response(responseBuilder()
.implementation(ConfigMap.class))
)
.PUT("themes/{name}/activation", this::activateTheme,
builder -> builder.operationId("activateTheme")
.description("Activate a theme by name.")
.tag(tag)
.parameter(parameterBuilder()
.name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(Theme.class))
)
.GET("themes", this::listThemes, .GET("themes", this::listThemes,
builder -> { builder -> {
builder.operationId("ListThemes") builder.operationId("ListThemes")
@ -129,9 +165,143 @@ public class ThemeEndpoint implements CustomEndpoint {
QueryParamBuildUtil.buildParametersFromType(builder, ThemeQuery.class); QueryParamBuildUtil.buildParametersFromType(builder, ThemeQuery.class);
} }
) )
.GET("themes/-/activation", this::fetchActivatedTheme,
builder -> builder.operationId("fetchActivatedTheme")
.description("Fetch the activated theme.")
.tag(tag)
.response(responseBuilder()
.implementation(Theme.class))
)
.GET("themes/{name}/setting", this::fetchThemeSetting,
builder -> builder.operationId("fetchThemeSetting")
.description("Fetch setting of theme.")
.tag(tag)
.parameter(parameterBuilder()
.name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(Setting.class))
)
.GET("themes/{name}/config", this::fetchThemeConfig,
builder -> builder.operationId("fetchThemeConfig")
.description("Fetch configMap of theme by configured configMapName.")
.tag(tag)
.parameter(parameterBuilder()
.name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(ConfigMap.class))
)
.build(); .build();
} }
private Mono<ServerResponse> activateTheme(ServerRequest request) {
final var activatedThemeName = request.pathVariable("name");
return client.fetch(Theme.class, activatedThemeName)
.switchIfEmpty(Mono.error(new NotFoundException("Theme not found.")))
.flatMap(theme -> systemEnvironmentFetcher.fetch(SystemSetting.Theme.GROUP,
SystemSetting.Theme.class)
.flatMap(themeSetting -> {
// update active theme config
themeSetting.setActive(activatedThemeName);
return systemEnvironmentFetcher.getConfigMap()
.filter(configMap -> configMap.getData() != null)
.map(configMap -> {
var themeConfigJson = JsonUtils.objectToJson(themeSetting);
configMap.getData()
.put(SystemSetting.Theme.GROUP, themeConfigJson);
return configMap;
});
})
.flatMap(client::update)
.retryWhen(Retry.backoff(5, Duration.ofMillis(300))
.filter(OptimisticLockingFailureException.class::isInstance)
)
.thenReturn(theme)
)
.flatMap(activatedTheme -> ServerResponse.ok().bodyValue(activatedTheme));
}
private Mono<ServerResponse> updateThemeConfig(ServerRequest request) {
final var themeName = request.pathVariable("name");
return client.fetch(Theme.class, themeName)
.doOnNext(theme -> {
String configMapName = theme.getSpec().getConfigMapName();
if (StringUtils.isBlank(configMapName)) {
throw new ServerWebInputException(
"Unable to complete the request because the theme configMapName is blank.");
}
})
.flatMap(theme -> {
final var configMapName = theme.getSpec().getConfigMapName();
return request.bodyToMono(ConfigMap.class)
.doOnNext(configMapToUpdate -> {
var configMapNameToUpdate = configMapToUpdate.getMetadata().getName();
if (!configMapName.equals(configMapNameToUpdate)) {
throw new ServerWebInputException(
"The name from the request body does not match the theme "
+ "configMapName name.");
}
})
.flatMap(configMapToUpdate -> client.fetch(ConfigMap.class, configMapName)
.map(persisted -> {
configMapToUpdate.getMetadata()
.setVersion(persisted.getMetadata().getVersion());
return configMapToUpdate;
})
.switchIfEmpty(client.create(configMapToUpdate))
)
.flatMap(client::update)
.retryWhen(Retry.backoff(5, Duration.ofMillis(300))
.filter(OptimisticLockingFailureException.class::isInstance)
);
})
.flatMap(configMap -> ServerResponse.ok().bodyValue(configMap));
}
private Mono<ServerResponse> fetchThemeConfig(ServerRequest request) {
return themeNameInPathVariableOrActivated(request)
.flatMap(themeName -> client.fetch(Theme.class, themeName))
.mapNotNull(theme -> theme.getSpec().getConfigMapName())
.flatMap(configMapName -> client.fetch(ConfigMap.class, configMapName))
.flatMap(configMap -> ServerResponse.ok().bodyValue(configMap));
}
private Mono<ServerResponse> fetchActivatedTheme(ServerRequest request) {
return systemEnvironmentFetcher.fetch(SystemSetting.Theme.GROUP, SystemSetting.Theme.class)
.map(SystemSetting.Theme::getActive)
.flatMap(activatedName -> client.fetch(Theme.class, activatedName))
.flatMap(theme -> ServerResponse.ok().bodyValue(theme));
}
private Mono<ServerResponse> fetchThemeSetting(ServerRequest request) {
return themeNameInPathVariableOrActivated(request)
.flatMap(name -> client.fetch(Theme.class, name))
.mapNotNull(theme -> theme.getSpec().getSettingName())
.flatMap(settingName -> client.fetch(Setting.class, settingName))
.flatMap(setting -> ServerResponse.ok().bodyValue(setting));
}
private Mono<String> themeNameInPathVariableOrActivated(ServerRequest request) {
Assert.notNull(request, "request must not be null.");
return Mono.fromSupplier(() -> request.pathVariable("name"))
.flatMap(name -> {
if ("-".equals(name)) {
return systemEnvironmentFetcher.fetch(SystemSetting.Theme.GROUP,
SystemSetting.Theme.class)
.mapNotNull(SystemSetting.Theme::getActive)
.defaultIfEmpty(name);
}
return Mono.just(name);
});
}
public static class ThemeQuery extends IListRequest.QueryListRequest { public static class ThemeQuery extends IListRequest.QueryListRequest {
public ThemeQuery(MultiValueMap<String, String> queryParams) { public ThemeQuery(MultiValueMap<String, String> queryParams) {

View File

@ -15,7 +15,7 @@ rules:
resources: [ "themes" ] resources: [ "themes" ]
verbs: [ "*" ] verbs: [ "*" ]
- apiGroups: [ "api.console.halo.run" ] - apiGroups: [ "api.console.halo.run" ]
resources: [ "themes", "themes/reload", "themes/resetconfig" ] resources: [ "themes", "themes/reload", "themes/resetconfig", "themes/config", "themes/activation" ]
verbs: [ "*" ] verbs: [ "*" ]
- nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ] - nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ]
verbs: [ "create" ] verbs: [ "create" ]
@ -36,6 +36,6 @@ rules:
resources: [ "themes" ] resources: [ "themes" ]
verbs: [ "get", "list" ] verbs: [ "get", "list" ]
- apiGroups: [ "api.console.halo.run" ] - apiGroups: [ "api.console.halo.run" ]
resources: [ "themes" ] resources: [ "themes", "themes/activation", "themes/setting", "themes/config" ]
verbs: [ "get", "list" ] verbs: [ "get", "list" ]

View File

@ -30,8 +30,13 @@ import org.springframework.util.FileSystemUtils;
import org.springframework.util.ResourceUtils; import org.springframework.util.ResourceUtils;
import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata; import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.ThemeRootGetter; import run.halo.app.infra.ThemeRootGetter;
import run.halo.app.theme.TemplateEngineManager; import run.halo.app.theme.TemplateEngineManager;
@ -53,6 +58,12 @@ class ThemeEndpointTest {
@Mock @Mock
TemplateEngineManager templateEngineManager; TemplateEngineManager templateEngineManager;
@Mock
private ReactiveExtensionClient client;
@Mock
private SystemConfigurableEnvironmentFetcher environmentFetcher;
@InjectMocks @InjectMocks
ThemeEndpoint themeEndpoint; ThemeEndpoint themeEndpoint;
@ -179,4 +190,126 @@ class ThemeEndpointTest {
.exchange() .exchange()
.expectStatus().isOk(); .expectStatus().isOk();
} }
@Nested
class UpdateThemeConfigTest {
@Test
void updateWhenConfigMapNameIsNull() {
Theme theme = new Theme();
theme.setMetadata(new Metadata());
theme.setSpec(new Theme.ThemeSpec());
theme.getSpec().setConfigMapName(null);
when(client.fetch(eq(Theme.class), eq("fake-theme"))).thenReturn(Mono.just(theme));
webTestClient.put()
.uri("/themes/fake-theme/config")
.exchange()
.expectStatus().isBadRequest();
}
@Test
void updateWhenConfigMapNameNotMatch() {
Theme theme = new Theme();
theme.setMetadata(new Metadata());
theme.setSpec(new Theme.ThemeSpec());
theme.getSpec().setConfigMapName("fake-config-map");
when(client.fetch(eq(Theme.class), eq("fake-theme"))).thenReturn(Mono.just(theme));
webTestClient.put()
.uri("/themes/fake-theme/config")
.body(Mono.fromSupplier(() -> {
ConfigMap configMap = new ConfigMap();
configMap.setMetadata(new Metadata());
configMap.getMetadata().setName("not-match");
return configMap;
}), ConfigMap.class)
.exchange()
.expectStatus().isBadRequest();
}
@Test
void updateWhenConfigMapNameMatch() {
Theme theme = new Theme();
theme.setMetadata(new Metadata());
theme.setSpec(new Theme.ThemeSpec());
theme.getSpec().setConfigMapName("fake-config-map");
when(client.fetch(eq(Theme.class), eq("fake-theme"))).thenReturn(Mono.just(theme));
when(client.fetch(eq(ConfigMap.class), eq("fake-config-map"))).thenReturn(Mono.empty());
when(client.create(any(ConfigMap.class))).thenReturn(Mono.empty());
webTestClient.put()
.uri("/themes/fake-theme/config")
.body(Mono.fromSupplier(() -> {
ConfigMap configMap = new ConfigMap();
configMap.setMetadata(new Metadata());
configMap.getMetadata().setName("fake-config-map");
return configMap;
}), ConfigMap.class)
.exchange()
.expectStatus().isOk();
}
}
@Test
void fetchActivatedTheme() {
when(environmentFetcher.fetch(eq(SystemSetting.Theme.GROUP), eq(SystemSetting.Theme.class)))
.thenReturn(Mono.fromSupplier(() -> {
SystemSetting.Theme theme = new SystemSetting.Theme();
theme.setActive("fake-activated");
return theme;
}));
when(client.fetch(eq(Theme.class), eq("fake-activated"))).thenReturn(Mono.empty());
webTestClient.get()
.uri("/themes/-/activation")
.exchange()
.expectStatus().isOk();
verify(client).fetch(eq(Theme.class), eq("fake-activated"));
}
@Test
void fetchThemeSetting() {
Theme theme = new Theme();
theme.setMetadata(new Metadata());
theme.getMetadata().setName("fake");
theme.setSpec(new Theme.ThemeSpec());
theme.getSpec().setSettingName("fake-setting");
when(client.fetch(eq(Setting.class), eq("fake-setting")))
.thenReturn(Mono.just(new Setting()));
when(client.fetch(eq(Theme.class), eq("fake"))).thenReturn(Mono.just(theme));
webTestClient.get()
.uri("/themes/fake/setting")
.exchange()
.expectStatus().isOk();
verify(client).fetch(eq(Setting.class), eq("fake-setting"));
verify(client).fetch(eq(Theme.class), eq("fake"));
}
@Test
void fetchThemeConfig() {
Theme theme = new Theme();
theme.setMetadata(new Metadata());
theme.getMetadata().setName("fake");
theme.setSpec(new Theme.ThemeSpec());
theme.getSpec().setConfigMapName("fake-config");
when(client.fetch(eq(ConfigMap.class), eq("fake-config")))
.thenReturn(Mono.just(new ConfigMap()));
when(client.fetch(eq(Theme.class), eq("fake"))).thenReturn(Mono.just(theme));
webTestClient.get()
.uri("/themes/fake/config")
.exchange()
.expectStatus().isOk();
verify(client).fetch(eq(ConfigMap.class), eq("fake-config"));
verify(client).fetch(eq(Theme.class), eq("fake"));
}
} }