feat(security)!: make basic auth required on OSS (#9688)

* feat(security)!: make basic auth required on OSS

* clean(security)!: put the auth filter code into a publisher

* clean(security)!: add unit tests

* fix(core): merge

---------

Co-authored-by: nKwiatkowski <nkwiatkowski@kestra.io>
This commit is contained in:
Nicolas K.
2025-07-08 10:41:47 +02:00
committed by GitHub
parent 6a4397fdfd
commit eafaf32938
16 changed files with 268 additions and 299 deletions

View File

@@ -74,9 +74,6 @@ kestra:
path: /tmp/kestra-wd/tmp path: /tmp/kestra-wd/tmp
anonymous-usage-report: anonymous-usage-report:
enabled: false enabled: false
server:
basic-auth:
enabled: false
datasources: datasources:
postgres: postgres:

View File

@@ -130,9 +130,6 @@ datasources:
username: kestra username: kestra
password: k3str4 password: k3str4
kestra: kestra:
server:
basic-auth:
enabled: false
encryption: encryption:
secret-key: 3ywuDa/Ec61VHkOX3RlI9gYq7CaD0mv0Pf3DHtAXA6U= secret-key: 3ywuDa/Ec61VHkOX3RlI9gYq7CaD0mv0Pf3DHtAXA6U=
repository: repository:

View File

@@ -184,7 +184,6 @@ kestra:
server: server:
basic-auth: basic-auth:
enabled: false
# These URLs will not be authenticated, by default we open some of the Micronaut default endpoints but not all for security reasons # These URLs will not be authenticated, by default we open some of the Micronaut default endpoints but not all for security reasons
open-urls: open-urls:
- "/ping" - "/ping"

View File

@@ -57,7 +57,6 @@ services:
kestra: kestra:
server: server:
basic-auth: basic-auth:
enabled: false
username: "admin@kestra.io" # it must be a valid email address username: "admin@kestra.io" # it must be a valid email address
password: kestra password: kestra
repository: repository:

View File

@@ -14,9 +14,6 @@ kestra:
secret-key: not_really_a_secret_only_for_unit_test secret-key: not_really_a_secret_only_for_unit_test
anonymous-usage-report: anonymous-usage-report:
enabled: false enabled: false
server:
basic-auth:
enabled: false
datasources: datasources:
postgres: postgres:
url: jdbc:postgresql://postgres:5432/kestra_unit url: jdbc:postgresql://postgres:5432/kestra_unit

View File

@@ -90,7 +90,7 @@ public class MiscController {
@Get("/configs") @Get("/configs")
@ExecuteOn(TaskExecutors.IO) @ExecuteOn(TaskExecutors.IO)
@Operation(tags = {"Misc"}, summary = "Retrieve the instance configuration.", description = "Global endpoint available to all users.") @Operation(tags = {"Misc"}, summary = "Retrieve the instance configuration.", description = "Global endpoint available to all users.")
public Configuration getConfiguration() throws JsonProcessingException { public Configuration getConfiguration() throws JsonProcessingException {
Configuration.ConfigurationBuilder<?, ?> builder = Configuration Configuration.ConfigurationBuilder<?, ?> builder = Configuration
.builder() .builder()
.uuid(instanceService.fetch()) .uuid(instanceService.fetch())
@@ -104,8 +104,7 @@ public class MiscController {
.preview(Preview.builder() .preview(Preview.builder()
.initial(this.initialPreviewRows) .initial(this.initialPreviewRows)
.max(this.maxPreviewRows) .max(this.maxPreviewRows)
.build() .build())
).isBasicAuthEnabled(basicAuthService.isEnabled())
.isAiEnabled(applicationContext.containsBean(AiController.class)) .isAiEnabled(applicationContext.containsBean(AiController.class))
.systemNamespace(namespaceUtils.getSystemFlowNamespace()) .systemNamespace(namespaceUtils.getSystemFlowNamespace())
.resourceToFilters(QueryFilter.Resource.asResourceList()) .resourceToFilters(QueryFilter.Resource.asResourceList())
@@ -174,8 +173,6 @@ public class MiscController {
Preview preview; Preview preview;
Boolean isBasicAuthEnabled;
String systemNamespace; String systemNamespace;
List<String> hiddenLabelsPrefixes; List<String> hiddenLabelsPrefixes;

View File

@@ -17,12 +17,12 @@ import io.micronaut.web.router.RouteMatchUtils;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import org.reactivestreams.Publisher; import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.Base64; import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.Optional; import java.util.Optional;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@Filter("/**") @Filter("/**")
@Requires(property = "kestra.server-type", pattern = "(WEBSERVER|STANDALONE)") @Requires(property = "kestra.server-type", pattern = "(WEBSERVER|STANDALONE)")
@@ -42,15 +42,10 @@ public class AuthenticationFilter implements HttpServerFilter {
@Override @Override
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, ServerFilterChain chain) { public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, ServerFilterChain chain) {
return Mono.fromCallable(() -> basicAuthService.isEnabled()) return Mono.fromCallable(basicAuthService::configuration)
.subscribeOn(Schedulers.boundedElastic()) .subscribeOn(Schedulers.boundedElastic())
.flux() .flux()
.switchMap(enabled -> { .flatMap(basicAuthConfiguration -> {
if (!enabled) {
return chain.proceed(request);
}
BasicAuthService.SaltedBasicAuthConfiguration basicAuthConfiguration = this.basicAuthService.configuration();
boolean isOpenUrl = Optional.ofNullable(basicAuthConfiguration.getOpenUrls()) boolean isOpenUrl = Optional.ofNullable(basicAuthConfiguration.getOpenUrls())
.map(Collection::stream) .map(Collection::stream)
.map(stream -> stream.anyMatch(s -> request.getPath().startsWith(s))) .map(stream -> stream.anyMatch(s -> request.getPath().startsWith(s)))
@@ -68,13 +63,14 @@ public class AuthenticationFilter implements HttpServerFilter {
if (basicAuth.isEmpty() || if (basicAuth.isEmpty() ||
!basicAuth.get().username().equals(basicAuthConfiguration.getUsername()) || !basicAuth.get().username().equals(basicAuthConfiguration.getUsername()) ||
!AuthUtils.encodePassword(basicAuthConfiguration.getSalt(), basicAuth.get().password()).equals(basicAuthConfiguration.getPassword()) !AuthUtils.encodePassword(basicAuthConfiguration.getSalt(),
basicAuth.get().password()).equals(basicAuthConfiguration.getPassword())
) { ) {
return Flux.just(HttpResponse.unauthorized().header("WWW-Authenticate", PREFIX + " realm=" + basicAuthConfiguration.getRealm())); return Flux.just(HttpResponse.unauthorized());
} }
return chain.proceed(request); return chain.proceed(request);
}); }) ;
} }
@SuppressWarnings("rawtypes") @SuppressWarnings("rawtypes")

View File

@@ -50,22 +50,21 @@ public class BasicAuthService {
@PostConstruct @PostConstruct
private void init() { private void init() {
if (Boolean.TRUE.equals(this.basicAuthConfiguration.getEnabled())) { if (basicAuthConfiguration == null){
this.save(this.basicAuthConfiguration); settingRepository.save(Setting.builder()
} else if (Boolean.FALSE.equals(this.basicAuthConfiguration.getEnabled())) { .key(BASIC_AUTH_SETTINGS_KEY)
this.unsecure(); .value(new BasicAuthConfiguration())
.build());
} else if (basicAuthConfiguration.getUsername() == null || basicAuthConfiguration.getPassword() == null){
settingRepository.save(Setting.builder()
.key(BASIC_AUTH_SETTINGS_KEY)
.value(basicAuthConfiguration)
.build());
} else {
save(basicAuthConfiguration);
} }
} }
public boolean isEnabled() {
BasicAuthConfiguration basicAuthConfiguration = configuration();
if (basicAuthConfiguration == null) {
return false;
}
return Boolean.TRUE.equals(basicAuthConfiguration.getEnabled()) && basicAuthConfiguration.getUsername() != null && basicAuthConfiguration.getPassword() != null;
}
public void save(BasicAuthConfiguration basicAuthConfiguration) { public void save(BasicAuthConfiguration basicAuthConfiguration) {
save(null, basicAuthConfiguration); save(null, basicAuthConfiguration);
} }
@@ -75,6 +74,10 @@ public class BasicAuthService {
throw new IllegalArgumentException("Invalid username for Basic Authentication. Please provide a valid email address."); throw new IllegalArgumentException("Invalid username for Basic Authentication. Please provide a valid email address.");
} }
if (basicAuthConfiguration.getUsername() == null) {
throw new IllegalArgumentException("No user name set for Basic Authentication. Please provide a user name.");
}
if (basicAuthConfiguration.getPassword() == null) { if (basicAuthConfiguration.getPassword() == null) {
throw new IllegalArgumentException("No password set for Basic Authentication. Please provide a password."); throw new IllegalArgumentException("No password set for Basic Authentication. Please provide a password.");
} }
@@ -114,18 +117,6 @@ public class BasicAuthService {
} }
} }
public void unsecure() {
BasicAuthConfiguration configuration = configuration();
if (configuration == null || Boolean.FALSE.equals(configuration.getEnabled())) {
return;
}
settingRepository.save(Setting.builder()
.key(BASIC_AUTH_SETTINGS_KEY)
.value(configuration.withEnabled(false))
.build());
}
public SaltedBasicAuthConfiguration configuration() { public SaltedBasicAuthConfiguration configuration() {
return settingRepository.findByKey(BASIC_AUTH_SETTINGS_KEY) return settingRepository.findByKey(BASIC_AUTH_SETTINGS_KEY)
.map(Setting::getValue) .map(Setting::getValue)
@@ -138,8 +129,6 @@ public class BasicAuthService {
@EqualsAndHashCode @EqualsAndHashCode
@ConfigurationProperties("kestra.server.basic-auth") @ConfigurationProperties("kestra.server.basic-auth")
public static class BasicAuthConfiguration { public static class BasicAuthConfiguration {
@With
private Boolean enabled;
private String username; private String username;
protected String password; protected String password;
private String realm; private String realm;
@@ -148,13 +137,11 @@ public class BasicAuthService {
@SuppressWarnings("MnInjectionPoints") @SuppressWarnings("MnInjectionPoints")
@ConfigurationInject @ConfigurationInject
public BasicAuthConfiguration( public BasicAuthConfiguration(
@Nullable Boolean enabled,
@Nullable String username, @Nullable String username,
@Nullable String password, @Nullable String password,
@Nullable String realm, @Nullable String realm,
@Nullable List<String> openUrls @Nullable List<String> openUrls
) { ) {
this.enabled = enabled;
this.username = username; this.username = username;
this.password = password; this.password = password;
this.realm = Optional.ofNullable(realm).orElse("Kestra"); this.realm = Optional.ofNullable(realm).orElse("Kestra");
@@ -165,12 +152,11 @@ public class BasicAuthService {
String username, String username,
String password String password
) { ) {
this(true, username, password, null, null); this(username, password, null, null);
} }
public BasicAuthConfiguration(BasicAuthConfiguration basicAuthConfiguration) { public BasicAuthConfiguration(BasicAuthConfiguration basicAuthConfiguration) {
if (basicAuthConfiguration != null) { if (basicAuthConfiguration != null) {
this.enabled = basicAuthConfiguration.getEnabled();
this.username = basicAuthConfiguration.getUsername(); this.username = basicAuthConfiguration.getUsername();
this.password = basicAuthConfiguration.getPassword(); this.password = basicAuthConfiguration.getPassword();
this.realm = basicAuthConfiguration.getRealm(); this.realm = basicAuthConfiguration.getRealm();
@@ -181,7 +167,6 @@ public class BasicAuthService {
@VisibleForTesting @VisibleForTesting
BasicAuthConfiguration withUsernamePassword(String username, String password) { BasicAuthConfiguration withUsernamePassword(String username, String password) {
return new BasicAuthConfiguration( return new BasicAuthConfiguration(
this.enabled,
username, username,
password, password,
this.realm, this.realm,

View File

@@ -1,33 +0,0 @@
package io.kestra.webserver.controllers.api;
import io.kestra.webserver.services.BasicAuthService;
import io.micronaut.context.annotation.Property;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.reactor.http.client.ReactorHttpClient;
import io.kestra.core.junit.annotations.KestraTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@KestraTest
@Property(name = "kestra.server.basic-auth.enabled", value = "true")
class MiscControllerSecuredTest {
@Inject
@Client("/")
ReactorHttpClient client;
@Inject
private BasicAuthService.BasicAuthConfiguration basicAuthConfiguration;
@Test
void getConfiguration() {
var response = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/configs").basicAuth(
basicAuthConfiguration.getUsername(),
basicAuthConfiguration.getPassword()
), MiscController.Configuration.class);
assertThat(response.getIsBasicAuthEnabled()).isTrue();
}
}

View File

@@ -1,7 +1,10 @@
package io.kestra.webserver.controllers.api; package io.kestra.webserver.controllers.api;
import static org.assertj.core.api.Assertions.assertThat;
import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.webserver.services.BasicAuthService; import io.kestra.webserver.services.BasicAuthService;
import io.kestra.webserver.services.BasicAuthService.BasicAuthConfiguration;
import io.micronaut.context.annotation.Property; import io.micronaut.context.annotation.Property;
import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.annotation.Client; import io.micronaut.http.client.annotation.Client;
@@ -11,8 +14,6 @@ import jakarta.inject.Inject;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@KestraTest @KestraTest
@Property(name = "kestra.system-flows.namespace", value = "some.system.ns") @Property(name = "kestra.system-flows.namespace", value = "some.system.ns")
class MiscControllerTest { class MiscControllerTest {
@@ -23,6 +24,9 @@ class MiscControllerTest {
@Inject @Inject
BasicAuthService basicAuthService; BasicAuthService basicAuthService;
@Inject
BasicAuthConfiguration basicAuthConfiguration;
@Test @Test
void ping() { void ping() {
var response = client.toBlocking().retrieve("/ping", String.class); var response = client.toBlocking().retrieve("/ping", String.class);
@@ -38,7 +42,6 @@ class MiscControllerTest {
assertThat(response.getUuid()).isNotNull(); assertThat(response.getUuid()).isNotNull();
assertThat(response.getIsTaskRunEnabled()).isFalse(); assertThat(response.getIsTaskRunEnabled()).isFalse();
assertThat(response.getIsAnonymousUsageEnabled()).isTrue(); assertThat(response.getIsAnonymousUsageEnabled()).isTrue();
assertThat(response.getIsBasicAuthEnabled()).isFalse();
assertThat(response.getIsAiEnabled()).isFalse(); assertThat(response.getIsAiEnabled()).isFalse();
assertThat(response.getSystemNamespace()).isEqualTo("some.system.ns"); assertThat(response.getSystemNamespace()).isEqualTo("some.system.ns");
} }
@@ -70,7 +73,7 @@ class MiscControllerTest {
MiscController.Configuration.class) MiscController.Configuration.class)
); );
} finally { } finally {
basicAuthService.unsecure(); basicAuthService.save(basicAuthConfiguration);
} }
} }
} }

View File

@@ -1,36 +1,36 @@
package io.kestra.webserver.controllers.api; package io.kestra.webserver.controllers.api;
import io.kestra.core.Helpers;
import io.kestra.core.models.collectors.Usage;
import io.micronaut.http.HttpRequest;
import io.micronaut.reactor.http.client.ReactorHttpClient;
import org.junit.jupiter.api.Test;
import java.net.URISyntaxException;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.core.models.collectors.Usage;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.reactor.http.client.ReactorHttpClient;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
@KestraTest
class MiscUsageControllerTest { class MiscUsageControllerTest {
@Inject
@Client("/")
ReactorHttpClient client;
@Test @Test
void usages() throws URISyntaxException { void usages() {
Helpers.runApplicationContext(new String[]{"test"}, Map.of("kestra.server-type", "STANDALONE"), (applicationContext, embeddedServer) -> { var response = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/main/usages/all"), Usage.class);
try (ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL())) {
var response = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/main/usages/all"), Usage.class); assertThat(response.getUuid()).isNotNull();
assertThat(response.getVersion()).isNotNull();
assertThat(response.getUuid()).isNotNull(); assertThat(response.getStartTime()).isNotNull();
assertThat(response.getVersion()).isNotNull(); assertThat(response.getEnvironments()).contains("test");
assertThat(response.getStartTime()).isNotNull(); assertThat(response.getStartTime()).isNotNull();
assertThat(response.getEnvironments()).contains("test"); assertThat(response.getHost().getUuid()).isNotNull();
assertThat(response.getStartTime()).isNotNull(); assertThat(response.getHost().getHardware().getLogicalProcessorCount()).isNotNull();
assertThat(response.getHost().getUuid()).isNotNull(); assertThat(response.getHost().getJvm().getName()).isNotNull();
assertThat(response.getHost().getHardware().getLogicalProcessorCount()).isNotNull(); assertThat(response.getHost().getOs().getFamily()).isNotNull();
assertThat(response.getHost().getJvm().getName()).isNotNull(); assertThat(response.getConfigurations().getRepositoryType()).isEqualTo("h2");
assertThat(response.getHost().getOs().getFamily()).isNotNull(); assertThat(response.getConfigurations().getQueueType()).isEqualTo("h2");
assertThat(response.getConfigurations().getRepositoryType()).isEqualTo("h2");
assertThat(response.getConfigurations().getQueueType()).isEqualTo("h2");
}
});
} }
} }

View File

@@ -5,23 +5,30 @@ import io.kestra.core.docs.DocumentationWithSchema;
import io.kestra.core.docs.InputType; import io.kestra.core.docs.InputType;
import io.kestra.core.docs.Plugin; import io.kestra.core.docs.Plugin;
import io.kestra.core.docs.PluginIcon; import io.kestra.core.docs.PluginIcon;
import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.core.models.annotations.PluginSubGroup; import io.kestra.core.models.annotations.PluginSubGroup;
import io.kestra.plugin.core.debug.Return; import io.kestra.plugin.core.debug.Return;
import io.kestra.plugin.core.log.Log; import io.kestra.plugin.core.log.Log;
import io.micronaut.core.type.Argument; import io.micronaut.core.type.Argument;
import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.reactor.http.client.ReactorHttpClient; import io.micronaut.reactor.http.client.ReactorHttpClient;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.net.URISyntaxException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@KestraTest
class PluginControllerTest { class PluginControllerTest {
@Inject
@Client("/")
ReactorHttpClient client;
public static final String PATH = "/api/v1/plugins"; public static final String PATH = "/api/v1/plugins";
@BeforeAll @BeforeAll
@@ -30,205 +37,170 @@ class PluginControllerTest {
} }
@Test @Test
void plugins() throws URISyntaxException { void plugins() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { List<Plugin> list = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH),
Argument.listOf(Plugin.class)
);
List<Plugin> list = client.toBlocking().retrieve( assertThat(list.size()).isEqualTo(2);
HttpRequest.GET(PATH),
Argument.listOf(Plugin.class)
);
assertThat(list.size()).isEqualTo(2); Plugin template = list.stream()
.filter(plugin -> plugin.getTitle().equals("plugin-template-test"))
.findFirst()
.orElseThrow();
Plugin template = list.stream() assertThat(template.getTitle()).isEqualTo("plugin-template-test");
.filter(plugin -> plugin.getTitle().equals("plugin-template-test")) assertThat(template.getGroup()).isEqualTo("io.kestra.plugin.templates");
.findFirst() assertThat(template.getDescription()).isEqualTo("Plugin template for Kestra");
.orElseThrow();
assertThat(template.getTitle()).isEqualTo("plugin-template-test"); assertThat(template.getTasks().size()).isEqualTo(1);
assertThat(template.getGroup()).isEqualTo("io.kestra.plugin.templates"); assertThat(template.getTasks().getFirst()).isEqualTo("io.kestra.plugin.templates.ExampleTask");
assertThat(template.getDescription()).isEqualTo("Plugin template for Kestra");
assertThat(template.getTasks().size()).isEqualTo(1); assertThat(template.getGuides().size()).isEqualTo(2);
assertThat(template.getTasks().getFirst()).isEqualTo("io.kestra.plugin.templates.ExampleTask"); assertThat(template.getGuides().getFirst()).isEqualTo("authentication");
assertThat(template.getGuides().size()).isEqualTo(2); Plugin core = list.stream()
assertThat(template.getGuides().getFirst()).isEqualTo("authentication"); .filter(plugin -> plugin.getTitle().equals("core"))
.findFirst()
.orElseThrow();
Plugin core = list.stream() assertThat(core.getCategories()).containsExactlyInAnyOrder(PluginSubGroup.PluginCategory.STORAGE, PluginSubGroup.PluginCategory.CORE);
.filter(plugin -> plugin.getTitle().equals("core"))
.findFirst()
.orElseThrow();
assertThat(core.getCategories()).containsExactlyInAnyOrder(PluginSubGroup.PluginCategory.STORAGE, PluginSubGroup.PluginCategory.CORE); // classLoader can lead to duplicate plugins for the core, just verify that the response is still the same
list = client.toBlocking().retrieve(
HttpRequest.GET(PATH),
Argument.listOf(Plugin.class)
);
// classLoader can lead to duplicate plugins for the core, just verify that the response is still the same assertThat(list.size()).isEqualTo(2);
list = client.toBlocking().retrieve(
HttpRequest.GET(PATH),
Argument.listOf(Plugin.class)
);
assertThat(list.size()).isEqualTo(2);
});
} }
@Test @Test
void icons() throws URISyntaxException { void icons() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { Map<String, PluginIcon> list = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/icons"),
Argument.mapOf(String.class, PluginIcon.class)
);
Map<String, PluginIcon> list = client.toBlocking().retrieve( assertThat(list.entrySet().stream()
HttpRequest.GET(PATH + "/icons"), .filter(e -> e.getKey().equals(Log.class.getName()))
Argument.mapOf(String.class, PluginIcon.class) .findFirst().orElseThrow().getValue().getIcon()).isNotNull();
); // test an alias
assertThat(list.entrySet().stream()
assertThat(list.entrySet().stream().filter(e -> e.getKey().equals(Log.class.getName())).findFirst().orElseThrow().getValue().getIcon()).isNotNull(); .filter(e -> e.getKey().equals("io.kestra.core.tasks.log.Log"))
// test an alias .findFirst().orElseThrow().getValue().getIcon()).isNotNull();
assertThat(list.entrySet().stream().filter(e -> e.getKey().equals("io.kestra.core.tasks.log.Log")).findFirst().orElseThrow().getValue().getIcon()).isNotNull();
});
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Test @Test
void returnTask() throws URISyntaxException { void returnTask() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { DocumentationWithSchema doc = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/" + Return.class.getName()),
DocumentationWithSchema.class
);
DocumentationWithSchema doc = client.toBlocking().retrieve( assertThat(doc.getMarkdown()).contains("io.kestra.plugin.core.debug.Return");
HttpRequest.GET(PATH + "/" + Return.class.getName()), assertThat(doc.getMarkdown()).contains("Return a value for debugging purposes.");
DocumentationWithSchema.class assertThat(doc.getMarkdown()).contains("The templated string to render");
); assertThat(doc.getMarkdown()).contains("The generated string");
assertThat(((Map<String, Object>) doc.getSchema().getProperties().get("properties")).size()).isEqualTo(1);
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.core.debug.Return"); assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
assertThat(doc.getMarkdown()).contains("Return a value for debugging purposes.");
assertThat(doc.getMarkdown()).contains("The templated string to render");
assertThat(doc.getMarkdown()).contains("The generated string");
assertThat(((Map<String, Object>) doc.getSchema().getProperties().get("properties")).size()).isEqualTo(1);
assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
});
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Test @Test
void docs() throws URISyntaxException { void docs() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { DocumentationWithSchema doc = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/io.kestra.plugin.templates.ExampleTask"),
DocumentationWithSchema.class
);
DocumentationWithSchema doc = client.toBlocking().retrieve( assertThat(doc.getMarkdown()).contains("io.kestra.plugin.templates.ExampleTask");
HttpRequest.GET(PATH + "/io.kestra.plugin.templates.ExampleTask"), assertThat(((Map<String, Object>) doc.getSchema().getProperties().get("properties")).size()).isEqualTo(5);
DocumentationWithSchema.class assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
);
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.templates.ExampleTask");
assertThat(((Map<String, Object>) doc.getSchema().getProperties().get("properties")).size()).isEqualTo(5);
assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
});
} }
@Test @Test
void docWithAlert() throws URISyntaxException { void docWithAlert() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { DocumentationWithSchema doc = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/io.kestra.plugin.core.state.Set"),
DocumentationWithSchema.class
);
DocumentationWithSchema doc = client.toBlocking().retrieve( assertThat(doc.getMarkdown()).contains("io.kestra.plugin.core.state.Set");
HttpRequest.GET(PATH + "/io.kestra.plugin.core.state.Set"), assertThat(doc.getMarkdown()).contains("::: warning\n");
DocumentationWithSchema.class
);
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.core.state.Set");
assertThat(doc.getMarkdown()).contains("::: warning\n");
});
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Test @Test
void taskWithBase() throws URISyntaxException { void taskWithBase() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { DocumentationWithSchema doc = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/io.kestra.plugin.templates.ExampleTask?all=true"),
DocumentationWithSchema.class
);
DocumentationWithSchema doc = client.toBlocking().retrieve( Map<String, Map<String, Object>> properties = (Map<String, Map<String, Object>>) doc.getSchema().getProperties().get("properties");
HttpRequest.GET(PATH + "/io.kestra.plugin.templates.ExampleTask?all=true"),
DocumentationWithSchema.class
);
Map<String, Map<String, Object>> properties = (Map<String, Map<String, Object>>) doc.getSchema().getProperties().get("properties"); assertThat(doc.getMarkdown()).contains("io.kestra.plugin.templates.ExampleTask");
assertThat(properties.size()).isEqualTo(17);
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.templates.ExampleTask"); assertThat(properties.get("id").size()).isEqualTo(4);
assertThat(properties.size()).isEqualTo(17); assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
assertThat(properties.get("id").size()).isEqualTo(4);
assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
});
} }
@Test @Test
void flow() throws URISyntaxException { void flow() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { Map<String, Object> doc = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/schemas/flow"),
Map<String, Object> doc = client.toBlocking().retrieve( Argument.mapOf(String.class, Object.class)
HttpRequest.GET(PATH + "/schemas/flow"), );
Argument.mapOf(String.class, Object.class)
);
assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.flows.Flow"); assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.flows.Flow");
});
} }
@Test @Test
void template() throws URISyntaxException { void template() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { Map<String, Object> doc = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/schemas/template"),
Map<String, Object> doc = client.toBlocking().retrieve( Argument.mapOf(String.class, Object.class)
HttpRequest.GET(PATH + "/schemas/template"), );
Argument.mapOf(String.class, Object.class)
);
assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.templates.Template"); assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.templates.Template");
});
} }
@Test @Test
void task() throws URISyntaxException { void task() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { Map<String, Object> doc = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/schemas/task"),
Map<String, Object> doc = client.toBlocking().retrieve( Argument.mapOf(String.class, Object.class)
HttpRequest.GET(PATH + "/schemas/task"), );
Argument.mapOf(String.class, Object.class)
);
assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.tasks.Task"); assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.tasks.Task");
});
} }
@Test @Test
void inputs() throws URISyntaxException { void inputs() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { List<InputType> doc = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/inputs"),
List<InputType> doc = client.toBlocking().retrieve( Argument.listOf(InputType.class)
HttpRequest.GET(PATH + "/inputs"), );
Argument.listOf(InputType.class)
);
assertThat(doc.size()).isEqualTo(19); assertThat(doc.size()).isEqualTo(19);
});
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@Test @Test
void input() throws URISyntaxException { void input() {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> { DocumentationWithSchema doc = client.toBlocking().retrieve(
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL()); HttpRequest.GET(PATH + "/inputs/STRING"),
DocumentationWithSchema doc = client.toBlocking().retrieve( DocumentationWithSchema.class
HttpRequest.GET(PATH + "/inputs/STRING"), );
DocumentationWithSchema.class
);
assertThat(doc.getSchema().getProperties().size()).isEqualTo(3); assertThat(doc.getSchema().getProperties().size()).isEqualTo(3);
Map<String, Object> properties = (Map<String, Object>) doc.getSchema().getProperties().get("properties"); Map<String, Object> properties = (Map<String, Object>) doc.getSchema().getProperties().get("properties");
assertThat(properties.size()).isEqualTo(8); assertThat(properties.size()).isEqualTo(8);
// assertThat(((Map<String, Object>) properties.get("name")).get("$deprecated"), is(true)); assertThat(((Map<String, Object>) properties.get("name")).get("$deprecated")).isEqualTo(true);
});
} }
} }

View File

@@ -1,22 +1,23 @@
package io.kestra.webserver.filter; package io.kestra.webserver.filter;
import io.kestra.webserver.services.BasicAuthService; import io.kestra.webserver.services.BasicAuthService;
import io.micronaut.context.annotation.Property;
import io.micronaut.context.annotation.Value;
import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpStatus; import io.micronaut.http.HttpStatus;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.client.annotation.Client; import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException; import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.reactor.http.client.ReactorHttpClient; import io.micronaut.reactor.http.client.ReactorHttpClient;
import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.junit.annotations.KestraTest;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@KestraTest @KestraTest
@Property(name = "kestra.server.basic-auth.enabled", value = "true")
class AuthenticationFilterTest { class AuthenticationFilterTest {
@Inject @Inject
@Client("/") @Client("/")
@@ -25,10 +26,11 @@ class AuthenticationFilterTest {
@Inject @Inject
private BasicAuthService.BasicAuthConfiguration basicAuthConfiguration; private BasicAuthService.BasicAuthConfiguration basicAuthConfiguration;
@Inject
private AuthenticationFilter filter;
@Test @Test
void testUnauthorized() { void testUnauthorized() {
assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange("/api/v1/configs"));
assertThrows(HttpClientResponseException.class, () -> client.toBlocking() assertThrows(HttpClientResponseException.class, () -> client.toBlocking()
.exchange(HttpRequest.GET("/api/v1/configs").basicAuth("anonymous", "hacker"))); .exchange(HttpRequest.GET("/api/v1/configs").basicAuth("anonymous", "hacker")));
} }
@@ -57,4 +59,35 @@ class AuthenticationFilterTest {
assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode());
} }
@Test
void should_unauthorized_with_wrong_username() {
HttpClientResponseException e = assertThrows(HttpClientResponseException.class,
() -> client.toBlocking()
.exchange(HttpRequest.GET("/api/v1/configs").basicAuth(
"incorrect",
basicAuthConfiguration.getPassword()
)));
assertThat(e.getResponse().getStatus().getCode()).isEqualTo(HttpStatus.UNAUTHORIZED.getCode());
}
@Test
void should_unauthorized_with_wrong_password() {
HttpClientResponseException e = assertThrows(HttpClientResponseException.class,
() -> client.toBlocking()
.exchange(HttpRequest.GET("/api/v1/configs").basicAuth(
basicAuthConfiguration.getUsername(),
"incorrect"
)));
assertThat(e.getResponse().getStatus().getCode()).isEqualTo(HttpStatus.UNAUTHORIZED.getCode());
}
@Test
void should_unauthorized_without_token(){
MutableHttpResponse<?> response = Mono.from(filter.doFilter(
HttpRequest.GET("/api/v1/configs"), null)).block();
assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.UNAUTHORIZED.getCode());
}
} }

View File

@@ -0,0 +1,49 @@
package io.kestra.webserver.filter;
import io.kestra.webserver.services.BasicAuthService;
import io.kestra.webserver.services.BasicAuthService.BasicAuthConfiguration;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.env.Environment;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MutableHttpRequest;
import io.micronaut.http.annotation.Filter;
import io.micronaut.http.filter.ClientFilterChain;
import io.micronaut.http.filter.HttpClientFilter;
import io.micronaut.http.filter.ServerFilterPhase;
import jakarta.inject.Inject;
import java.util.Base64;
import org.reactivestreams.Publisher;
@Filter("/**")
@Requires(env = Environment.TEST)
public class TestAuthFilter implements HttpClientFilter {
@Inject
private BasicAuthConfiguration basicAuthConfiguration;
@Inject
private BasicAuthService basicAuthService;
@Override
public Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request,
ClientFilterChain chain) {
//Basic auth may be removed from the database by jdbcTestUtils.drop(); / jdbcTestUtils.migrate();
//We need it back to be able to run the tests and avoid NPE while checking the basic authorization
if (basicAuthService.configuration() == null){
basicAuthService.save(basicAuthConfiguration);
}
//Add basic authorization header if no header are present in the query
if (request.getHeaders().getAuthorization().isEmpty()) {
String token = "Basic " + Base64.getEncoder().encodeToString(
(basicAuthConfiguration.getUsername() + ":" + basicAuthConfiguration.getPassword()).getBytes());
request.getHeaders().add(HttpHeaders.AUTHORIZATION, token);
}
return chain.proceed(request);
}
@Override
public int getOrder() {
return ServerFilterPhase.SECURITY.order() - 1;
}
}

View File

@@ -2,7 +2,6 @@ package io.kestra.webserver.services;
import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import io.kestra.core.models.Setting; import io.kestra.core.models.Setting;
import io.kestra.core.queues.QueueException;
import io.kestra.core.repositories.SettingRepositoryInterface; import io.kestra.core.repositories.SettingRepositoryInterface;
import io.kestra.core.serializers.JacksonMapper; import io.kestra.core.serializers.JacksonMapper;
import io.kestra.core.services.InstanceService; import io.kestra.core.services.InstanceService;
@@ -41,7 +40,7 @@ class BasicAuthServiceTest {
post(urlEqualTo("/v1/reports/events")) post(urlEqualTo("/v1/reports/events"))
.willReturn(aResponse().withStatus(200)) .willReturn(aResponse().withStatus(200))
); );
ctx = ApplicationContext.run(Map.of("kestra.server.basic-auth.enabled", "true"), Environment.TEST); ctx = ApplicationContext.run(Map.of(), Environment.TEST);
basicAuthService = ctx.getBean(BasicAuthService.class); basicAuthService = ctx.getBean(BasicAuthService.class);
basicAuthConfiguration = ctx.getBean(BasicAuthService.BasicAuthConfiguration.class); basicAuthConfiguration = ctx.getBean(BasicAuthService.BasicAuthConfiguration.class);
@@ -57,16 +56,14 @@ class BasicAuthServiceTest {
} }
@Test @Test
void initFromYamlConfig() throws TimeoutException, QueueException { void initFromYamlConfig() throws TimeoutException {
assertThat(basicAuthService.isEnabled()).isTrue();
assertConfigurationMatchesApplicationYaml(); assertConfigurationMatchesApplicationYaml();
awaitOssAuthEventApiCall("admin@kestra.io"); awaitOssAuthEventApiCall("admin@kestra.io");
} }
@Test @Test
void secure() throws TimeoutException, QueueException { void secure() throws TimeoutException {
IllegalArgumentException illegalArgumentException = Assertions.assertThrows( IllegalArgumentException illegalArgumentException = Assertions.assertThrows(
IllegalArgumentException.class, IllegalArgumentException.class,
() -> basicAuthService.save(basicAuthConfiguration.withUsernamePassword("not-an-email", "password")) () -> basicAuthService.save(basicAuthConfiguration.withUsernamePassword("not-an-email", "password"))
@@ -80,24 +77,6 @@ class BasicAuthServiceTest {
awaitOssAuthEventApiCall("some@email.com"); awaitOssAuthEventApiCall("some@email.com");
} }
@Test
void unsecure() {
assertThat(basicAuthService.isEnabled()).isTrue();
BasicAuthService.SaltedBasicAuthConfiguration previousConfiguration = basicAuthService.configuration();
basicAuthService.unsecure();
assertThat(basicAuthService.isEnabled()).isFalse();
BasicAuthService.SaltedBasicAuthConfiguration newConfiguration = basicAuthService.configuration();
assertThat(newConfiguration.getEnabled()).isFalse();
assertThat(newConfiguration.getUsername()).isEqualTo(previousConfiguration.getUsername());
assertThat(newConfiguration.getPassword()).isEqualTo(previousConfiguration.getPassword());
assertThat(newConfiguration.getRealm()).isEqualTo(previousConfiguration.getRealm());
assertThat(newConfiguration.getOpenUrls()).isEqualTo(previousConfiguration.getOpenUrls());
}
private void assertConfigurationMatchesApplicationYaml() { private void assertConfigurationMatchesApplicationYaml() {
BasicAuthService.SaltedBasicAuthConfiguration actualConfiguration = basicAuthService.configuration(); BasicAuthService.SaltedBasicAuthConfiguration actualConfiguration = basicAuthService.configuration();
BasicAuthService.SaltedBasicAuthConfiguration applicationYamlConfiguration = new BasicAuthService.SaltedBasicAuthConfiguration( BasicAuthService.SaltedBasicAuthConfiguration applicationYamlConfiguration = new BasicAuthService.SaltedBasicAuthConfiguration(

View File

@@ -47,7 +47,6 @@ kestra:
access-log: access-log:
enabled: false enabled: false
basic-auth: basic-auth:
enabled: false
username: admin@kestra.io username: admin@kestra.io
password: kestra password: kestra
open-urls: open-urls: