From d2daa9870ca586d80588428d90caacd9739de583 Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Thu, 5 Sep 2024 13:08:02 -0400 Subject: [PATCH] Add initial changes to expose an endpoint for auth failure listener get call (#4641) Signed-off-by: Derek Ho Signed-off-by: Craig Perkins Co-authored-by: Craig Perkins --- .../api/AuthFailureListenersApiAction.java | 268 ++++++++++++++++++ .../security/dlic/rest/api/Endpoint.java | 1 + .../dlic/rest/api/SecurityRestApiActions.java | 1 + .../validation/RequestContentValidator.java | 7 + .../securityconf/impl/v7/ConfigV7.java | 36 ++- .../security/support/SecurityJsonNode.java | 8 + .../AuthFailureListenersApiActionTest.java | 209 ++++++++++++++ ...ilureListenersApiActionValidationTest.java | 70 +++++ .../RequestContentValidatorTest.java | 17 +- 9 files changed, 608 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiAction.java create mode 100644 src/test/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiActionTest.java create mode 100644 src/test/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiActionValidationTest.java diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiAction.java new file mode 100644 index 0000000000..63937befaa --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiAction.java @@ -0,0 +1,268 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.dlic.rest.api; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.opensearch.action.index.IndexResponse; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.dlic.rest.validation.EndpointValidator; +import org.opensearch.security.dlic.rest.validation.RequestContentValidator; +import org.opensearch.security.dlic.rest.validation.RequestContentValidator.DataType; +import org.opensearch.security.dlic.rest.validation.ValidationResult; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; +import org.opensearch.security.support.SecurityJsonNode; +import org.opensearch.threadpool.ThreadPool; + +import static org.opensearch.rest.RestRequest.Method.DELETE; +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.PUT; +import static org.opensearch.security.dlic.rest.api.Responses.badRequest; +import static org.opensearch.security.dlic.rest.api.Responses.badRequestMessage; +import static org.opensearch.security.dlic.rest.api.Responses.notFound; +import static org.opensearch.security.dlic.rest.api.Responses.ok; +import static org.opensearch.security.dlic.rest.api.Responses.response; +import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.ALLOWED_TRIES_DEFAULT; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.BLOCK_EXPIRY_SECONDS_DEFAULT; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.MAX_BLOCKED_CLIENTS_DEFAULT; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.MAX_TRACKED_CLIENTS_DEFAULT; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.TIME_WINDOW_SECONDS_DEFAULT; + +public class AuthFailureListenersApiAction extends AbstractApiAction { + + public static final String IP_TYPE = "ip"; + + public static final String USERNAME_TYPE = "username"; + + public static final String NAME_JSON_PROPERTY = "name"; + + public static final String TYPE_JSON_PROPERTY = "type"; + public static final String IGNORE_HOSTS_JSON_PROPERTY = "ignore_hosts"; + public static final String AUTHENTICATION_BACKEND_JSON_PROPERTY = "authentication_backend"; + public static final String ALLOWED_TRIES_JSON_PROPERTY = "allowed_tries"; + public static final String TIME_WINDOW_SECONDS_JSON_PROPERTY = "time_window_seconds"; + public static final String BLOCK_EXPIRY_JSON_PROPERTY = "block_expiry_seconds"; + public static final String MAX_BLOCKED_CLIENTS_JSON_PROPERTY = "max_blocked_clients"; + public static final String MAX_TRACKED_CLIENTS_JSON_PROPERTY = "max_tracked_clients"; + + private static final List ROUTES = addRoutesPrefix( + ImmutableList.of( + new Route(GET, "/authfailurelisteners"), + new Route(DELETE, "/authfailurelisteners/{name}"), + new Route(PUT, "/authfailurelisteners/{name}") + ) + ); + + protected AuthFailureListenersApiAction( + ClusterService clusterService, + ThreadPool threadPool, + SecurityApiDependencies securityApiDependencies + ) { + super(Endpoint.AUTHFAILURELISTENERS, clusterService, threadPool, securityApiDependencies); + this.requestHandlersBuilder.configureRequestHandlers(this::authFailureConfigApiRequestHandlers); + } + + @Override + public String getName() { + return "Auth failure listener actions to Retrieve / Update configs."; + } + + @Override + public List routes() { + return ROUTES; + } + + @Override + protected CType getConfigType() { + return CType.CONFIG; + } + + @Override + protected EndpointValidator createEndpointValidator() { + return new EndpointValidator() { + + @Override + public Endpoint endpoint() { + return endpoint; + } + + @Override + public RestApiAdminPrivilegesEvaluator restApiAdminPrivilegesEvaluator() { + return securityApiDependencies.restApiAdminPrivilegesEvaluator(); + } + + @Override + public RequestContentValidator createRequestContentValidator(Object... params) { + return RequestContentValidator.of(new RequestContentValidator.ValidationContext() { + @Override + public Object[] params() { + return params; + } + + @Override + public Settings settings() { + return securityApiDependencies.settings(); + } + + @Override + public Map allowedKeys() { + final ImmutableMap.Builder allowedKeys = ImmutableMap.builder(); + + return allowedKeys.put(TYPE_JSON_PROPERTY, DataType.STRING) + .put(IGNORE_HOSTS_JSON_PROPERTY, DataType.ARRAY) + .put(AUTHENTICATION_BACKEND_JSON_PROPERTY, DataType.STRING) + .put(ALLOWED_TRIES_JSON_PROPERTY, DataType.INTEGER) + .put(TIME_WINDOW_SECONDS_JSON_PROPERTY, DataType.INTEGER) + .put(BLOCK_EXPIRY_JSON_PROPERTY, DataType.INTEGER) + .put(MAX_BLOCKED_CLIENTS_JSON_PROPERTY, DataType.INTEGER) + .put(MAX_TRACKED_CLIENTS_JSON_PROPERTY, DataType.INTEGER) + .build(); + } + }); + } + }; + } + + private ToXContent authFailureContent(final ConfigV7 config) { + return (builder, params) -> { + builder.startObject(); + for (String name : config.dynamic.auth_failure_listeners.getListeners().keySet()) { + ConfigV7.AuthFailureListener listener = config.dynamic.auth_failure_listeners.getListeners().get(name); + builder.startObject(name); + builder.field(NAME_JSON_PROPERTY, name) + .field(TYPE_JSON_PROPERTY, listener.type) + .field(IGNORE_HOSTS_JSON_PROPERTY, listener.ignore_hosts) + .field(AUTHENTICATION_BACKEND_JSON_PROPERTY, listener.authentication_backend) + .field(ALLOWED_TRIES_JSON_PROPERTY, listener.allowed_tries) + .field(TIME_WINDOW_SECONDS_JSON_PROPERTY, listener.time_window_seconds) + .field(BLOCK_EXPIRY_JSON_PROPERTY, listener.block_expiry_seconds) + .field(MAX_BLOCKED_CLIENTS_JSON_PROPERTY, listener.max_blocked_clients) + .field(MAX_TRACKED_CLIENTS_JSON_PROPERTY, listener.max_tracked_clients); + builder.endObject(); + } + builder.endObject(); + return builder; + }; + } + + private void authFailureConfigApiRequestHandlers(RequestHandler.RequestHandlersBuilder requestHandlersBuilder) { + + requestHandlersBuilder.override( + GET, + (channel, request, client) -> loadConfiguration(getConfigType(), false, false).valid(configuration -> { + final var config = (ConfigV7) configuration.getCEntry(CType.CONFIG.toLCString()); + ok(channel, authFailureContent(config)); + }).error((status, toXContent) -> response(channel, status, toXContent)) + ).override(DELETE, (channel, request, client) -> loadConfiguration(getConfigType(), false, false).valid(configuration -> { + ConfigV7 config = (ConfigV7) configuration.getCEntry(CType.CONFIG.toLCString()); + + String listenerName = request.param(NAME_JSON_PROPERTY); + + // Try to remove the listener by name + if (config.dynamic.auth_failure_listeners.getListeners().remove(listenerName) == null) { + notFound(channel, "listener not found"); + } + saveOrUpdateConfiguration(client, configuration, new OnSucessActionListener<>(channel) { + @Override + public void onResponse(IndexResponse indexResponse) { + ok(channel, authFailureContent(config)); + } + }); + }).error((status, toXContent) -> response(channel, status, toXContent))) + .override(PUT, (channel, request, client) -> loadConfiguration(getConfigType(), false, false).valid(configuration -> { + ConfigV7 config = (ConfigV7) configuration.getCEntry(CType.CONFIG.toLCString()); + + String listenerName = request.param(NAME_JSON_PROPERTY); + + ObjectNode body = (ObjectNode) DefaultObjectMapper.readTree(request.content().utf8ToString()); + SecurityJsonNode authFailureListener = new SecurityJsonNode(body); + ValidationResult validationResult = validateAuthFailureListener(authFailureListener, listenerName); + + if (!validationResult.isValid()) { + badRequest(channel, validationResult.toString()); + return; + } + + // Try to put the listener by name + config.dynamic.auth_failure_listeners.getListeners() + .put(listenerName, createAuthFailureListenerWithDefaults(authFailureListener)); + saveOrUpdateConfiguration(client, configuration, new OnSucessActionListener<>(channel) { + @Override + public void onResponse(IndexResponse indexResponse) { + + ok(channel, authFailureContent(config)); + } + }); + }).error((status, toXContent) -> response(channel, status, toXContent))); + + } + + private ConfigV7.AuthFailureListener createAuthFailureListenerWithDefaults(SecurityJsonNode authFailureListener) { + List ignoreHosts = authFailureListener.get(IGNORE_HOSTS_JSON_PROPERTY).isNull() + ? Collections.emptyList() + : authFailureListener.get(IGNORE_HOSTS_JSON_PROPERTY).asList(); + + return new ConfigV7.AuthFailureListener( + authFailureListener.get(TYPE_JSON_PROPERTY).asString(), + authFailureListener.get(AUTHENTICATION_BACKEND_JSON_PROPERTY).asString(), + ignoreHosts, + authFailureListener.get(ALLOWED_TRIES_JSON_PROPERTY).asInt(ALLOWED_TRIES_DEFAULT), + authFailureListener.get(TIME_WINDOW_SECONDS_JSON_PROPERTY).asInt(TIME_WINDOW_SECONDS_DEFAULT), + authFailureListener.get(BLOCK_EXPIRY_JSON_PROPERTY).asInt(BLOCK_EXPIRY_SECONDS_DEFAULT), + authFailureListener.get(MAX_BLOCKED_CLIENTS_JSON_PROPERTY).asInt(MAX_BLOCKED_CLIENTS_DEFAULT), + authFailureListener.get(MAX_TRACKED_CLIENTS_JSON_PROPERTY).asInt(MAX_TRACKED_CLIENTS_DEFAULT) + ); + + } + + private ValidationResult validateAuthFailureListener(SecurityJsonNode authFailureListener, String name) { + if (name == null) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("name is required")); + } + if (authFailureListener.get(TYPE_JSON_PROPERTY).isNull()) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("type is required")); + } + if (!(Set.of(IP_TYPE, USERNAME_TYPE).contains(authFailureListener.get(TYPE_JSON_PROPERTY).asString()))) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("type must be username or ip")); + } + if (authFailureListener.get(TYPE_JSON_PROPERTY).asString().equals(USERNAME_TYPE) + && (authFailureListener.get(AUTHENTICATION_BACKEND_JSON_PROPERTY).isNull() + || !authFailureListener.get(AUTHENTICATION_BACKEND_JSON_PROPERTY).asString().equals("internal"))) { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("username auth failure listeners must have 'internal' authentication backend") + ); + } + if (authFailureListener.get(TYPE_JSON_PROPERTY).asString().equals(IP_TYPE) + && !authFailureListener.get(AUTHENTICATION_BACKEND_JSON_PROPERTY).isNull()) { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("ip auth failure listeners should not have an authentication backend") + ); + } + + return ValidationResult.success(authFailureListener); + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java index 84a447bcac..45be6c8596 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java @@ -24,6 +24,7 @@ public enum Endpoint { PERMISSIONSINFO, AUTHTOKEN, TENANTS, + AUTHFAILURELISTENERS, MIGRATE, VALIDATE, WHITELIST, diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java b/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java index 8ccf494d3d..ff1d0ef112 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java @@ -97,6 +97,7 @@ public static Collection getHandler( new AllowlistApiAction(Endpoint.ALLOWLIST, clusterService, threadPool, securityApiDependencies), new AuditApiAction(clusterService, threadPool, securityApiDependencies), new MultiTenancyConfigApiAction(clusterService, threadPool, securityApiDependencies), + new AuthFailureListenersApiAction(clusterService, threadPool, securityApiDependencies), new ConfigUpgradeApiAction(clusterService, threadPool, securityApiDependencies), new SecuritySSLCertsApiAction(clusterService, threadPool, securityKeyStore, certificatesReloadEnabled, securityApiDependencies), new CertificatesApiAction(clusterService, threadPool, securityApiDependencies) diff --git a/src/main/java/org/opensearch/security/dlic/rest/validation/RequestContentValidator.java b/src/main/java/org/opensearch/security/dlic/rest/validation/RequestContentValidator.java index db6d3b4883..097b953690 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/validation/RequestContentValidator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/validation/RequestContentValidator.java @@ -81,6 +81,7 @@ public static enum DataType { STRING, ARRAY, OBJECT, + INTEGER, BOOLEAN; } @@ -179,6 +180,7 @@ protected ValidationResult validateJsonKeys(final JsonNode jsonContent final Set allowed = new HashSet<>(validationContext.allowedKeys().keySet()); requestedKeys.removeAll(allowed); invalidKeys.addAll(requestedKeys); + if (!missingMandatoryKeys.isEmpty() || !invalidKeys.isEmpty() || !missingMandatoryOrKeys.isEmpty()) { this.validationError = ValidationError.INVALID_CONFIGURATION; return ValidationResult.error(RestStatus.BAD_REQUEST, this); @@ -196,6 +198,11 @@ private ValidationResult validateDataType(final JsonNode jsonContent) if (dataType != null) { JsonToken valueToken = parser.nextToken(); switch (dataType) { + case INTEGER: + if (valueToken != JsonToken.VALUE_NUMBER_INT) { + wrongDataTypes.put(currentName, "Integer expected"); + } + break; case STRING: if (valueToken != JsonToken.VALUE_STRING) { wrongDataTypes.put(currentName, "String expected"); diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index 0638d7a884..fb406fd83a 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -52,6 +52,12 @@ public class ConfigV7 { + public static int ALLOWED_TRIES_DEFAULT = 10; + public static int TIME_WINDOW_SECONDS_DEFAULT = 60 * 60; + public static int BLOCK_EXPIRY_SECONDS_DEFAULT = 60 * 10; + public static int MAX_BLOCKED_CLIENTS_DEFAULT = 100_000; + public static int MAX_TRACKED_CLIENTS_DEFAULT = 100_000; + public Dynamic dynamic; public ConfigV7() { @@ -227,11 +233,11 @@ public static class AuthFailureListener { public String type; public String authentication_backend; public List ignore_hosts; - public int allowed_tries = 10; - public int time_window_seconds = 60 * 60; - public int block_expiry_seconds = 60 * 10; - public int max_blocked_clients = 100_000; - public int max_tracked_clients = 100_000; + public int allowed_tries = ALLOWED_TRIES_DEFAULT; + public int time_window_seconds = TIME_WINDOW_SECONDS_DEFAULT; + public int block_expiry_seconds = BLOCK_EXPIRY_SECONDS_DEFAULT; + public int max_blocked_clients = MAX_BLOCKED_CLIENTS_DEFAULT; + public int max_tracked_clients = MAX_TRACKED_CLIENTS_DEFAULT; public AuthFailureListener() { super(); @@ -248,6 +254,26 @@ public AuthFailureListener(ConfigV6.AuthFailureListener v6) { this.max_tracked_clients = v6.max_tracked_clients; } + public AuthFailureListener( + String type, + String authentication_backend, + List ignore_hosts, + int allowed_tries, + int time_window_seconds, + int block_expiry_seconds, + int max_blocked_clients, + int max_tracked_clients + ) { + this.type = type; + this.authentication_backend = authentication_backend; + this.ignore_hosts = ignore_hosts; + this.allowed_tries = allowed_tries; + this.time_window_seconds = time_window_seconds; + this.block_expiry_seconds = block_expiry_seconds; + this.max_blocked_clients = max_blocked_clients; + this.max_tracked_clients = max_tracked_clients; + } + @JsonIgnore public String asJson() { try { diff --git a/src/main/java/org/opensearch/security/support/SecurityJsonNode.java b/src/main/java/org/opensearch/security/support/SecurityJsonNode.java index 04a6fabf5c..ffd4fbd68a 100644 --- a/src/main/java/org/opensearch/security/support/SecurityJsonNode.java +++ b/src/main/java/org/opensearch/security/support/SecurityJsonNode.java @@ -52,6 +52,14 @@ public String asString() { } } + public Integer asInt(Integer defaultValue) { + if (isNull(node)) { + return defaultValue; + } else { + return node.asInt(0); + } + } + private static boolean isNull(JsonNode node) { return node == null || node.isNull(); } diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiActionTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiActionTest.java new file mode 100644 index 0000000000..8e283ad0d4 --- /dev/null +++ b/src/test/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiActionTest.java @@ -0,0 +1,209 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.dlic.rest.api; + +import org.apache.hc.core5.http.Header; +import org.apache.http.HttpStatus; +import org.junit.Test; + +import org.opensearch.security.test.helper.rest.RestHelper; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.hamcrest.core.StringContains.containsString; + +public class AuthFailureListenersApiActionTest extends AbstractRestApiUnitTest { + + private static final Header ADMIN_FULL_ACCESS_USER = encodeBasicHeader("admin_all_access", "admin_all_access"); + private static final Header USER_NO_REST_API_ACCESS = encodeBasicHeader("admin", "admin"); + + @Test + public void testForbiddenAccess() throws Exception { + setupWithRestRoles(); + + rh.sendAdminCertificate = false; + RestHelper.HttpResponse getSettingResponse = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + USER_NO_REST_API_ACCESS + ); + assertThat(getSettingResponse.getBody(), getSettingResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); + RestHelper.HttpResponse updateSettingResponse = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"ip\",\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}", + USER_NO_REST_API_ACCESS + ); + assertThat(getSettingResponse.getBody(), updateSettingResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); + RestHelper.HttpResponse deleteSettingResponse = rh.executeDeleteRequest( + "/_plugins/_security/api/authfailurelisteners/test", + USER_NO_REST_API_ACCESS + ); + assertThat(getSettingResponse.getBody(), updateSettingResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); + } + + @Test + public void testFullAccess() throws Exception { + setupWithRestRoles(); + rh.sendAdminCertificate = true; + // Initial get returns no auth failure listeners + RestHelper.HttpResponse getAuthFailuresResponse = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat(getAuthFailuresResponse.getBody(), getAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + assertThat(getAuthFailuresResponse.getBody(), getAuthFailuresResponse.getBody(), equalTo("{}")); + + // Put a test auth failure listener + RestHelper.HttpResponse updateAuthFailuresResponse = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"ip\",\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}", + ADMIN_FULL_ACCESS_USER + ); + assertThat(updateAuthFailuresResponse.getBody(), updateAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + + // Get after put returns the test auth failure listener + RestHelper.HttpResponse getAuthFailuresResponseAfterPut = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat(getAuthFailuresResponseAfterPut.getBody(), getAuthFailuresResponseAfterPut.getStatusCode(), equalTo(HttpStatus.SC_OK)); + assertThat( + getAuthFailuresResponseAfterPut.getBody(), + getAuthFailuresResponseAfterPut.getBody(), + equalTo( + "{\"test\":{\"name\":\"test\",\"type\":\"ip\",\"ignore_hosts\":[],\"authentication_backend\":null,\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}}" + ) + ); + + // Delete the test auth failure listener + RestHelper.HttpResponse deleteAuthFailuresResponse = rh.executeDeleteRequest( + "/_plugins/_security/api/authfailurelisteners/test", + ADMIN_FULL_ACCESS_USER + ); + assertThat(deleteAuthFailuresResponse.getBody(), deleteAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + + // Get after delete returns no auth failure listener + RestHelper.HttpResponse getAuthFailuresResponseAfterDelete = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat( + getAuthFailuresResponseAfterDelete.getBody(), + getAuthFailuresResponseAfterDelete.getStatusCode(), + equalTo(HttpStatus.SC_OK) + ); + assertThat(getAuthFailuresResponseAfterDelete.getBody(), getAuthFailuresResponseAfterDelete.getBody(), equalTo("{}")); + } + + @Test + public void testInvalidDeleteScenarios() throws Exception { + setupWithRestRoles(); + + rh.sendAdminCertificate = true; + RestHelper.HttpResponse deleteAuthFailuresResponseNoExist = rh.executeDeleteRequest( + "/_plugins/_security/api/authfailurelisteners/test", + ADMIN_FULL_ACCESS_USER + ); + assertThat( + deleteAuthFailuresResponseNoExist.getBody(), + deleteAuthFailuresResponseNoExist.getStatusCode(), + equalTo(HttpStatus.SC_NOT_FOUND) + ); + assertThat(deleteAuthFailuresResponseNoExist.getBody(), containsString("listener not found")); + + } + + @Test + public void testInvalidPutScenarios() throws Exception { + setupWithRestRoles(); + + rh.sendAdminCertificate = true; + RestHelper.HttpResponse updateAuthFailuresResponseNoBackend = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"username\",\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}", + ADMIN_FULL_ACCESS_USER + ); + assertThat( + updateAuthFailuresResponseNoBackend.getBody(), + updateAuthFailuresResponseNoBackend.getStatusCode(), + equalTo(HttpStatus.SC_BAD_REQUEST) + ); + + RestHelper.HttpResponse updateAuthFailuresResponseNoType = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}", + ADMIN_FULL_ACCESS_USER + ); + assertThat( + updateAuthFailuresResponseNoType.getBody(), + updateAuthFailuresResponseNoType.getStatusCode(), + equalTo(HttpStatus.SC_BAD_REQUEST) + ); + + } + + @Test + public void testPutWithAllDefaults() throws Exception { + setupWithRestRoles(); + rh.sendAdminCertificate = true; + + // Put a test auth failure listener + RestHelper.HttpResponse updateAuthFailuresResponse = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"ip\"}", + ADMIN_FULL_ACCESS_USER + ); + assertThat(updateAuthFailuresResponse.getBody(), updateAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + + // Get after put returns the test auth failure listener with proper defaults set + RestHelper.HttpResponse getAuthFailuresResponseAfterPut = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat(getAuthFailuresResponseAfterPut.getBody(), getAuthFailuresResponseAfterPut.getStatusCode(), equalTo(HttpStatus.SC_OK)); + assertThat( + getAuthFailuresResponseAfterPut.getBody(), + getAuthFailuresResponseAfterPut.getBody(), + equalTo( + "{\"test\":{\"name\":\"test\",\"type\":\"ip\",\"ignore_hosts\":[],\"authentication_backend\":null,\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}}" + ) + ); + } + + @Test + public void testPutWithSomeDefaults() throws Exception { + setupWithRestRoles(); + rh.sendAdminCertificate = true; + + // Put another test auth failure listener with some fields provided + RestHelper.HttpResponse updateAuthFailuresResponse = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"ip\",\"allowed_tries\":88,\"time_window_seconds\":12345}", + ADMIN_FULL_ACCESS_USER + ); + assertThat(updateAuthFailuresResponse.getBody(), updateAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + + // Get after put returns the test auth failure listener with proper defaults set + RestHelper.HttpResponse getAuthFailuresResponseAfterPut = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat(getAuthFailuresResponseAfterPut.getBody(), getAuthFailuresResponseAfterPut.getStatusCode(), equalTo(HttpStatus.SC_OK)); + assertThat( + getAuthFailuresResponseAfterPut.getBody(), + getAuthFailuresResponseAfterPut.getBody(), + equalTo( + "{\"test\":{\"name\":\"test\",\"type\":\"ip\",\"ignore_hosts\":[],\"authentication_backend\":null,\"allowed_tries\":88,\"time_window_seconds\":12345,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}}" + ) + ); + } + +} diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiActionValidationTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiActionValidationTest.java new file mode 100644 index 0000000000..1982b7c738 --- /dev/null +++ b/src/test/java/org/opensearch/security/dlic/rest/api/AuthFailureListenersApiActionValidationTest.java @@ -0,0 +1,70 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.dlic.rest.api; + +import java.io.IOException; + +import org.junit.Test; + +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.rest.RestRequest; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; +import org.opensearch.security.util.FakeRestRequest; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class AuthFailureListenersApiActionValidationTest extends AbstractApiActionValidationTest { + + @Test + public void validateAllowedFields() throws IOException { + final var authFailureListenerApiActionRequestContentValidator = new AuthFailureListenersApiAction( + clusterService, + threadPool, + securityApiDependencies + ).createEndpointValidator().createRequestContentValidator(); + + final var authFailureListener = new ConfigV7.AuthFailureListener(); + + final var content = DefaultObjectMapper.writeValueAsString(objectMapper.valueToTree(authFailureListener), false); + + var validResult = authFailureListenerApiActionRequestContentValidator.validate( + FakeRestRequest.builder() + .withMethod(RestRequest.Method.PUT) + .withPath("_plugins/_security/api/authfailurelisteners/test") + .withContent(new BytesArray(content)) + .build() + ); + assertTrue(validResult.isValid()); + + final var invalidContent = objectMapper.createObjectNode() + .set( + "blah", + objectMapper.createObjectNode() + + ); + + var inValidResult = authFailureListenerApiActionRequestContentValidator.validate( + FakeRestRequest.builder() + .withMethod(RestRequest.Method.PUT) + .withPath("_plugins/_security/api/authfailurelisteners/test") + .withContent(new BytesArray(invalidContent.toString())) + .build() + ); + assertFalse(inValidResult.isValid()); + assertThat(inValidResult.status(), is(RestStatus.BAD_REQUEST)); + } +} diff --git a/src/test/java/org/opensearch/security/dlic/rest/validation/RequestContentValidatorTest.java b/src/test/java/org/opensearch/security/dlic/rest/validation/RequestContentValidatorTest.java index 561695106d..72ad0ae76a 100644 --- a/src/test/java/org/opensearch/security/dlic/rest/validation/RequestContentValidatorTest.java +++ b/src/test/java/org/opensearch/security/dlic/rest/validation/RequestContentValidatorTest.java @@ -143,12 +143,18 @@ public Map allowedKeys() { "b", RequestContentValidator.DataType.OBJECT, "c", - RequestContentValidator.DataType.ARRAY + RequestContentValidator.DataType.ARRAY, + "d", + RequestContentValidator.DataType.INTEGER ); } }); - final JsonNode payload = DefaultObjectMapper.objectMapper.createObjectNode().put("a", 1).put("b", "[]").put("c", "{}"); + final JsonNode payload = DefaultObjectMapper.objectMapper.createObjectNode() + .put("a", 1) + .put("b", "[]") + .put("c", "{}") + .put("d", "1"); when(httpRequest.content()).thenReturn(new BytesArray(payload.toString())); final ValidationResult validationResult = validator.validate(request); @@ -159,6 +165,7 @@ public Map allowedKeys() { assertThat(errorMessage.get("a").asText(), is("String expected")); assertThat(errorMessage.get("b").asText(), is("Object expected")); assertThat(errorMessage.get("c").asText(), is("Array expected")); + assertThat(errorMessage.get("d").asText(), is("Integer expected")); } @Test @@ -284,14 +291,16 @@ public Map allowedKeys() { "d", RequestContentValidator.DataType.STRING, "e", - RequestContentValidator.DataType.BOOLEAN + RequestContentValidator.DataType.BOOLEAN, + "f", + RequestContentValidator.DataType.INTEGER ); } }); ObjectNode payload = DefaultObjectMapper.objectMapper.createObjectNode().putObject("a"); payload.putArray("a").add("arrray"); - payload.put("b", true).put("d", "some_string").put("e", "true"); + payload.put("b", true).put("d", "some_string").put("e", "true").put("f", 1); payload.putObject("c"); when(httpRequest.content()).thenReturn(new BytesArray(payload.toString()));