From 614b7390efb423fb68afc7a4bcc920a269a2629e Mon Sep 17 00:00:00 2001 From: Andrey Pleskach Date: Tue, 28 May 2024 14:31:31 +0200 Subject: [PATCH] Common test for REST config entity Signed-off-by: Andrey Pleskach --- .../api/AbstractApiIntegrationTest.java | 47 ++-- ...bstractConfigEntityApiIntegrationTest.java | 240 ++++++++++++++++++ .../test/framework/TestSecurityConfig.java | 33 +++ 3 files changed, 301 insertions(+), 19 deletions(-) create mode 100644 src/integrationTest/java/org/opensearch/security/api/AbstractConfigEntityApiIntegrationTest.java diff --git a/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java index 4381359b27..d5a0e41a6d 100644 --- a/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java @@ -45,8 +45,8 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.hamcrest.Matchers.notNullValue; import static org.opensearch.security.CrossClusterSearchTests.PLUGINS_SECURITY_RESTAPI_ROLES_ENABLED; import static org.opensearch.security.OpenSearchSecurityPlugin.LEGACY_OPENDISTRO_PREFIX; @@ -182,7 +182,7 @@ private static String removeDashes(final String content) { } protected static String[] allRestAdminPermissions() { - final var permissions = new String[ENDPOINTS_WITH_PERMISSIONS.size() + 3]; // 2 actions for SSL + update config action + final var permissions = new String[ENDPOINTS_WITH_PERMISSIONS.size() + 1]; // 1 additional action for SSL update certs var counter = 0; for (final var e : ENDPOINTS_WITH_PERMISSIONS.entrySet()) { if (e.getKey() == Endpoint.SSL) { @@ -209,6 +209,11 @@ protected static String restAdminPermission(Endpoint endpoint, String action) { } } + protected String randomRestAdminPermission() { + final var permissions = List.of(allRestAdminPermissions()); + return randomFrom(permissions); + } + @AfterClass public static void stopCluster() throws IOException { if (localCluster != null) localCluster.close(); @@ -240,7 +245,7 @@ protected void withUser( final CertificateData certificateData, final CheckedConsumer restClientHandler ) throws Exception { - try (TestRestClient client = localCluster.getRestClient(user, password, certificateData)) { + try (final TestRestClient client = localCluster.getRestClient(user, password, certificateData)) { restClientHandler.accept(client); } } @@ -274,6 +279,12 @@ protected String apiPath(final String... path) { return fullPath.toString(); } + void badRequestWithMessage(final CheckedSupplier endpointCallback, final String expectedMessage) + throws Exception { + final var response = badRequest(endpointCallback); + assertThat(response.getBody(), response.getTextFromJsonBody("/message"), is(expectedMessage)); + } + TestRestClient.HttpResponse badRequest(final CheckedSupplier endpointCallback) throws Exception { final var response = endpointCallback.get(); @@ -286,9 +297,16 @@ TestRestClient.HttpResponse created(final CheckedSupplier endpointCallback, final String expectedMessage) + throws Exception { + final var response = forbidden(endpointCallback); + assertThat(response.getBody(), response.getTextFromJsonBody("/message"), is(expectedMessage)); + } + TestRestClient.HttpResponse forbidden(final CheckedSupplier endpointCallback) throws Exception { final var response = endpointCallback.get(); assertThat(response.getBody(), response.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); @@ -319,6 +337,12 @@ TestRestClient.HttpResponse notFound(final CheckedSupplier endpointCallback, final String expectedMessage) + throws Exception { + final var response = notFound(endpointCallback); + assertThat(response.getBody(), response.getTextFromJsonBody("/message"), is(expectedMessage)); + } + TestRestClient.HttpResponse ok(final CheckedSupplier endpointCallback) throws Exception { final var response = endpointCallback.get(); assertThat(response.getBody(), response.getStatusCode(), equalTo(HttpStatus.SC_OK)); @@ -330,7 +354,7 @@ TestRestClient.HttpResponse unauthorized(final CheckedSupplier endpointCallback) throws Exception { - final var response = endpointCallback.get(); - assertThat(response.getBody(), response.getTextFromJsonBody("/reason"), equalTo("`null` is not allowed as json array element")); - } - } diff --git a/src/integrationTest/java/org/opensearch/security/api/AbstractConfigEntityApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/AbstractConfigEntityApiIntegrationTest.java new file mode 100644 index 0000000000..11f93d3c5f --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/api/AbstractConfigEntityApiIntegrationTest.java @@ -0,0 +1,240 @@ +package org.opensearch.security.api; + +import java.util.Optional; +import java.util.StringJoiner; + +import org.hamcrest.Matcher; +import org.junit.Test; + +import org.opensearch.common.CheckedSupplier; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.oneOf; +import static org.opensearch.security.api.PatchPayloadHelper.addOp; +import static org.opensearch.security.api.PatchPayloadHelper.patch; +import static org.opensearch.security.api.PatchPayloadHelper.removeOp; +import static org.opensearch.security.api.PatchPayloadHelper.replaceOp; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; + +public abstract class AbstractConfigEntityApiIntegrationTest extends AbstractApiIntegrationTest { + + static { + clusterSettings.put(SECURITY_RESTAPI_ADMIN_ENABLED, true); + testSecurityConfig.withRestAdminUser(REST_ADMIN_USER, allRestAdminPermissions()); + } + + interface TestDescriptor { + + String entityJsonProperty(); + + default ToXContentObject entityPayload() { + return entityPayload(null, null, null); + } + + default ToXContentObject reservedEntityPayload() { + return entityPayload(null, true, null); + } + + default ToXContentObject hiddenEntityPayload() { + return entityPayload(true, null, null); + } + + default ToXContentObject staticEntityPayload() { + return entityPayload(null, null, true); + } + + ToXContentObject entityPayload(final Boolean hidden, final Boolean reserved, final Boolean _static); + + ToXContentObject jsonPropertyPayload(); + + default Optional rastAdminLimitedUser() { + return Optional.empty(); + } + + } + + private final String path; + + private final TestDescriptor testDescriptor; + + public AbstractConfigEntityApiIntegrationTest(final String path, final TestDescriptor testDescriptor) { + this.path = path; + this.testDescriptor = testDescriptor; + } + + @Override + protected String apiPath(String... paths) { + final StringJoiner fullPath = new StringJoiner("/").add(super.apiPath(path)); + if (paths != null) { + for (final var p : paths) { + fullPath.add(p); + } + } + return fullPath.toString(); + } + + @Test + public void forbiddenForRegularUsers() throws Exception { + withUser(NEW_USER, client -> { + forbidden(() -> client.putJson(apiPath("some_entity"), EMPTY_BODY)); + forbidden(() -> client.get(apiPath())); + forbidden(() -> client.get(apiPath("some_entity"))); + forbidden(() -> client.putJson(apiPath("some_entity"), EMPTY_BODY)); + forbidden(() -> client.patch(apiPath(), EMPTY_BODY)); + forbidden(() -> client.patch(apiPath("some_entity"), EMPTY_BODY)); + forbidden(() -> client.delete(apiPath("some_entity"))); + }); + } + + @Test + public void availableForAdminUser() throws Exception { + final var hiddenEntityName = randomAsciiAlphanumOfLength(10); + final var reservedEntityName = randomAsciiAlphanumOfLength(10); + withUser( + ADMIN_USER_NAME, + localCluster.getAdminCertificate(), + client -> created(() -> client.putJson(apiPath(hiddenEntityName), testDescriptor.hiddenEntityPayload())) + ); + withUser( + ADMIN_USER_NAME, + localCluster.getAdminCertificate(), + client -> created(() -> client.putJson(apiPath(reservedEntityName), testDescriptor.reservedEntityPayload())) + ); + + // can't see hidden resources + withUser(ADMIN_USER_NAME, client -> { + verifyNoHiddenEntities(() -> client.get(apiPath())); + creationOfReadOnlyEntityForbidden( + client, + (builder, params) -> testDescriptor.hiddenEntityPayload().toXContent(builder, params), + (builder, params) -> testDescriptor.reservedEntityPayload().toXContent(builder, params), + (builder, params) -> testDescriptor.staticEntityPayload().toXContent(builder, params) + ); + verifyUpdateAndDeleteHiddenConfigEntityForbidden(hiddenEntityName, client); + verifyUpdateAndDeleteReservedConfigEntityForbidden(reservedEntityName, client); + verifyCrudOperations(null, null, client); + verifyBadRequestOperations(client); + }); + } + + @Test + public void availableForTLSAdminUser() throws Exception { + withUser(ADMIN_USER_NAME, localCluster.getAdminCertificate(), this::availableForSuperAdminUser); + } + + @Test + public void availableForRESTAdminUser() throws Exception { + withUser(REST_ADMIN_USER, this::availableForSuperAdminUser); + if (testDescriptor.rastAdminLimitedUser().isPresent()) { + withUser(testDescriptor.rastAdminLimitedUser().get(), this::availableForSuperAdminUser); + } + } + + void availableForSuperAdminUser(final TestRestClient client) throws Exception { + creationOfReadOnlyEntityForbidden(client, (builder, params) -> testDescriptor.staticEntityPayload().toXContent(builder, params)); + verifyCrudOperations(true, null, client); + verifyCrudOperations(null, true, client); + verifyCrudOperations(null, null, client); + verifyBadRequestOperations(client); + forbiddenToCreateEntityWithRestAdminPermissions(client); + forbiddenToUpdateAndDeleteExistingEntityWithRestAdminPermissions(client); + } + + void verifyNoHiddenEntities(final CheckedSupplier endpointCallback) throws Exception { + final var body = ok(endpointCallback).bodyAsJsonNode(); + final var pretty = body.toPrettyString(); + final var it = body.elements(); + while (it.hasNext()) { + final var e = it.next(); + assertThat(pretty, not(e.get("hidden").asBoolean())); + } + } + + void creationOfReadOnlyEntityForbidden(final TestRestClient client, final ToXContentObject... entities) throws Exception { + for (final var configEntity : entities) { + assertInvalidKeys( + badRequest(() -> client.putJson(apiPath(randomAsciiAlphanumOfLength(10)), configEntity)), + is(oneOf("static", "hidden", "reserved")) + ); + badRequest(() -> client.patch(apiPath(), patch(addOp(randomAsciiAlphanumOfLength(10), configEntity)))); + } + } + + void assertNullValuesInArray(final TestRestClient.HttpResponse response) throws Exception { + assertThat(response.getBody(), response.getTextFromJsonBody("/reason"), equalTo("`null` is not allowed as json array element")); + } + + void assertInvalidKeys(final TestRestClient.HttpResponse response, final String expectedInvalidKeys) { + assertInvalidKeys(response, equalTo(expectedInvalidKeys)); + } + + void assertInvalidKeys(final TestRestClient.HttpResponse response, final Matcher expectedInvalidKeysMatcher) { + assertThat(response.getBody(), response.getTextFromJsonBody("/status"), is("error")); + assertThat(response.getBody(), response.getTextFromJsonBody("/reason"), equalTo("Invalid configuration")); + assertThat(response.getBody(), response.getTextFromJsonBody("/invalid_keys/keys"), expectedInvalidKeysMatcher); + } + + void assertSpecifyOneOf(final TestRestClient.HttpResponse response, final String expectedSpecifyOneOfKeys) { + assertThat(response.getBody(), response.getTextFromJsonBody("/status"), is("error")); + assertThat(response.getBody(), response.getTextFromJsonBody("/reason"), equalTo("Invalid configuration")); + assertThat(response.getBody(), response.getTextFromJsonBody("/specify_one_of/keys"), containsString(expectedSpecifyOneOfKeys)); + } + + void assertMissingMandatoryKeys(final TestRestClient.HttpResponse response, final String expectedKeys) { + assertThat(response.getBody(), response.getTextFromJsonBody("/status"), is("error")); + assertThat(response.getBody(), response.getTextFromJsonBody("/reason"), equalTo("Invalid configuration")); + assertThat(response.getBody(), response.getTextFromJsonBody("/missing_mandatory_keys/keys"), containsString(expectedKeys)); + } + + void verifyUpdateAndDeleteHiddenConfigEntityForbidden(final String hiddenEntityName, final TestRestClient client) throws Exception { + final var expectedErrorMessage = "Resource '" + hiddenEntityName + "' is not available."; + notFound(() -> client.putJson(apiPath(hiddenEntityName), testDescriptor.entityPayload()), expectedErrorMessage); + notFound( + () -> client.patch( + apiPath(hiddenEntityName), + patch(replaceOp(testDescriptor.entityJsonProperty(), testDescriptor.jsonPropertyPayload())) + ), + expectedErrorMessage + ); + notFound(() -> client.patch(apiPath(), patch(replaceOp(hiddenEntityName, testDescriptor.entityPayload()))), expectedErrorMessage); + notFound(() -> client.patch(apiPath(hiddenEntityName), patch(removeOp(testDescriptor.entityJsonProperty()))), expectedErrorMessage); + notFound(() -> client.patch(apiPath(), patch(removeOp(hiddenEntityName))), expectedErrorMessage); + notFound(() -> client.delete(apiPath(hiddenEntityName)), expectedErrorMessage); + } + + void verifyUpdateAndDeleteReservedConfigEntityForbidden(final String reservedEntityName, final TestRestClient client) throws Exception { + final var expectedErrorMessage = "Resource '" + reservedEntityName + "' is reserved."; + forbidden(() -> client.putJson(apiPath(reservedEntityName), testDescriptor.entityPayload()), expectedErrorMessage); + forbidden( + () -> client.patch( + apiPath(reservedEntityName), + patch(replaceOp(testDescriptor.entityJsonProperty(), testDescriptor.entityJsonProperty())) + ), + expectedErrorMessage + ); + forbidden( + () -> client.patch(apiPath(), patch(replaceOp(reservedEntityName, testDescriptor.entityPayload()))), + expectedErrorMessage + ); + forbidden(() -> client.patch(apiPath(), patch(removeOp(reservedEntityName))), expectedErrorMessage); + forbidden( + () -> client.patch(apiPath(reservedEntityName), patch(removeOp(testDescriptor.entityJsonProperty()))), + expectedErrorMessage + ); + forbidden(() -> client.delete(apiPath(reservedEntityName)), expectedErrorMessage); + } + + void forbiddenToCreateEntityWithRestAdminPermissions(final TestRestClient client) throws Exception {} + + void forbiddenToUpdateAndDeleteExistingEntityWithRestAdminPermissions(final TestRestClient client) throws Exception {} + + abstract void verifyBadRequestOperations(final TestRestClient client) throws Exception; + + abstract void verifyCrudOperations(final Boolean hidden, final Boolean reserved, final TestRestClient client) throws Exception; +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java index 79f10a76cf..e4e7f8f4de 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java @@ -313,6 +313,8 @@ public String type() { private Boolean reserved = null; + private Boolean _static = null; + public ActionGroup(String name, Type type, String... allowedActions) { this(name, null, type, allowedActions); } @@ -333,16 +335,38 @@ public ActionGroup hidden(boolean hidden) { return this; } + public boolean hidden() { + return hidden != null && hidden; + } + public ActionGroup reserved(boolean reserved) { this.reserved = reserved; return this; } + public boolean reserved() { + return reserved != null && reserved; + } + + public ActionGroup _static(boolean _static) { + this._static = _static; + return this; + } + + public boolean _static() { + return _static != null && _static; + } + + public List allowedActions() { + return allowedActions; + } + @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject(); if (hidden != null) builder.field("hidden", hidden); if (reserved != null) builder.field("reserved", reserved); + if (_static != null) builder.field("static", _static); builder.field("type", type.type()); builder.field("allowed_actions", allowedActions); if (description != null) builder.field("description", description); @@ -366,6 +390,7 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(name, description, type, allowedActions, hidden, reserved); } + } public static final class User implements UserCredentialsHolder, ToXContentObject { @@ -605,6 +630,8 @@ public static class RoleMapping implements ToXContentObject { private Boolean reserved; + private Boolean _static; + private final String description; private List backendRoles = new ArrayList<>(); @@ -632,6 +659,11 @@ public RoleMapping reserved(boolean reserved) { return this; } + public RoleMapping _static(boolean _static) { + this._static = _static; + return this; + } + public RoleMapping users(String... users) { this.users.addAll(Arrays.asList(users)); return this; @@ -652,6 +684,7 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.startObject(); if (hidden != null) builder.field("hidden", hidden); if (reserved != null) builder.field("reserved", reserved); + if (_static != null) builder.field("static", _static); if (users != null && !users.isEmpty()) builder.field("users", users); if (hosts != null && !hosts.isEmpty()) builder.field("hosts", hosts); if (description != null) builder.field("description", description);