diff --git a/adapters/adapters-base/src/main/java/org/apache/cassandra/sidecar/adapters/base/CassandraAdapter.java b/adapters/adapters-base/src/main/java/org/apache/cassandra/sidecar/adapters/base/CassandraAdapter.java index 587b5dc8e..c9735efd7 100644 --- a/adapters/adapters-base/src/main/java/org/apache/cassandra/sidecar/adapters/base/CassandraAdapter.java +++ b/adapters/adapters-base/src/main/java/org/apache/cassandra/sidecar/adapters/base/CassandraAdapter.java @@ -34,6 +34,7 @@ import org.apache.cassandra.sidecar.common.server.ICassandraAdapter; import org.apache.cassandra.sidecar.common.server.JmxClient; import org.apache.cassandra.sidecar.common.server.MetricsOperations; +import org.apache.cassandra.sidecar.common.server.RolesOperations; import org.apache.cassandra.sidecar.common.server.StorageOperations; import org.apache.cassandra.sidecar.common.server.TableOperations; import org.apache.cassandra.sidecar.common.server.dns.DnsResolver; @@ -136,6 +137,13 @@ public MetricsOperations metricsOperations() return new CassandraMetricsOperations(jmxClient, tableSchemaFetcher, this); } + @Override + @NotNull + public RolesOperations rolesOperations() + { + return new CassandraRolesOperations(this); + } + /** * {@inheritDoc} */ diff --git a/adapters/adapters-base/src/main/java/org/apache/cassandra/sidecar/adapters/base/CassandraRolesOperations.java b/adapters/adapters-base/src/main/java/org/apache/cassandra/sidecar/adapters/base/CassandraRolesOperations.java new file mode 100644 index 000000000..663bff911 --- /dev/null +++ b/adapters/adapters-base/src/main/java/org/apache/cassandra/sidecar/adapters/base/CassandraRolesOperations.java @@ -0,0 +1,393 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.adapters.base; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Random; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import com.google.common.annotations.VisibleForTesting; + +import com.datastax.driver.core.ExecutionInfo; +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.SimpleStatement; +import org.apache.cassandra.sidecar.common.request.data.GenerateRoleRequestPayload; +import org.apache.cassandra.sidecar.common.response.GenerateRoleResponse; +import org.apache.cassandra.sidecar.common.server.ICassandraAdapter; +import org.apache.cassandra.sidecar.common.server.RolesOperations; + +public class CassandraRolesOperations implements RolesOperations +{ + private final UUIDGenerator generator; + private final ICassandraAdapter cassandraAdapter; + + public CassandraRolesOperations(ICassandraAdapter cassandraAdapter) + { + this.cassandraAdapter = cassandraAdapter; + this.generator = new UUIDGenerator(); + } + + public GenerateRoleResponse generateRole(GenerateRoleRequestPayload payload) + { + String role = null; + String password = null; + if (payload.sidecarGeneration) + { + role = generator.generate(payload.roleNameOptions); + // we do not accept any parameters for password, it will be just uuid + if (payload.passwordGeneration) + password = generator.generate(Map.of()); + } + + try + { + String query; + if (payload.sidecarGeneration) + { + query = CqlRoleBuilder.createRole(role) + .password(password) + .login(payload.login) + .superuser(payload.superuser) + .build(); + } + else + { + query = CqlRoleBuilder.createGeneratedRole() + .generatedPassword(payload.passwordGeneration) + .login(payload.login) + .superuser(payload.superuser) + .option(payload.roleNameOptions) + .build(); + } + + ResultSet resultSet = cassandraAdapter.executeLocal(new SimpleStatement(query)); + + ExecutionInfo executionInfo = resultSet.getExecutionInfo(); + List warnings = executionInfo.getWarnings(); + if (warnings != null && !warnings.isEmpty()) + throw new IllegalStateException(String.join(" ", warnings)); + + if (payload.sidecarGeneration) + { + return new GenerateRoleResponse(role, password); + } + else + { + Row one = resultSet.one(); + return new GenerateRoleResponse(one.getString("generated_role"), one.getString("generated_password")); + } + } + catch (Throwable t) + { + throw new IllegalStateException(t); + } + } + + /** + * Generator of UUIDs where first character is always a character. + */ + @VisibleForTesting + public static class UUIDGenerator + { + public static final String NAME_PREFIX_KEY = "name_prefix"; + public static final String NAME_SUFFIX_KEY = "name_suffix"; + public static final String NAME_SIZE = "name_size"; + public static final int MINIMUM_NAME_SIZE = 10; + + private static final Pattern PATTERN = Pattern.compile("-"); + public static final char[] FIRST_CHARS = { 'a', 'b', 'c', 'd', 'e', 'f' }; + + private static final Random random = new Random(); + // lenght of UUID without hyphens + // generated name can be longer than this if it has prefix / suffix + public static final int MAXIMUM_NAME_SIZE = 32; + + public String generate(Map options) + { + int size = getSize(options); + + // to always start on a letter, so we do not need to wrap in '' + char firstChar = FIRST_CHARS[random.nextInt(6)]; + String uuid = UUID.randomUUID().toString().toLowerCase(Locale.ROOT); + String uuidWithoutHyphens = PATTERN.matcher(uuid).replaceAll(""); + String name = firstChar + uuidWithoutHyphens.substring(1); + + name = name.substring(0, size); + name = enrich(NAME_PREFIX_KEY, name, options); + name = enrich(NAME_SUFFIX_KEY, name, options); + + return name; + } + + private String enrich(String key, String generatedValue, Map options) + { + if (options == null || options.isEmpty()) + return generatedValue; + + if (options.containsKey(key)) + { + Object value = options.get(key); + + if (value == null) + throw new IllegalArgumentException("Value of " + key + " cannot be null."); + + if (NAME_PREFIX_KEY.equals(key)) + generatedValue = value + generatedValue; + else if (NAME_SUFFIX_KEY.equals(key)) + generatedValue = generatedValue + value; + } + + return generatedValue; + } + + private int getSize(Map options) + { + Object sizeObject; + if (options == null) + { + sizeObject = MAXIMUM_NAME_SIZE; + } + else if (options.containsKey(NAME_SIZE)) + { + Object nameSizeValue = options.get(NAME_SIZE); + if (nameSizeValue != null) + sizeObject = nameSizeValue; + else + throw new IllegalArgumentException("Value of " + NAME_SIZE + " has to be strictly positive integer."); + } + else + { + sizeObject = MAXIMUM_NAME_SIZE; + } + + int size; + + if (sizeObject instanceof String) + { + try + { + size = Integer.parseInt((String) sizeObject); + } + catch (Throwable t) + { + throw new IllegalArgumentException("Value '" + sizeObject + "' can't be converted to integer."); + } + } + else size = ((Number) sizeObject).intValue(); + + if (size < MINIMUM_NAME_SIZE) + throw new IllegalArgumentException("Value of " + NAME_SIZE + " parameter has to be at least " + MINIMUM_NAME_SIZE + '.'); + + if (size > MAXIMUM_NAME_SIZE) + throw new IllegalArgumentException("Generator generates names of maximum length " + MAXIMUM_NAME_SIZE + ". " + + "You want to generate with length " + size + '.'); + + return size; + } + } + + public static class CqlRoleBuilder + { + private final String roleName; + private String password; + private boolean login; + private boolean superuser; + private final Map customOptions = new LinkedHashMap<>(); + private boolean ifNotExists = false; + private boolean generateRole = false; + private boolean generatePassword = false; + + private CqlRoleBuilder(boolean generatedRole) + { + this.generateRole = generatedRole; + this.roleName = null; + } + + private CqlRoleBuilder(String roleName) + { + this.roleName = Objects.requireNonNull(roleName, "Role name cannot be null"); + } + + /** + * Start building a CREATE ROLE statement + * + * @param roleName the name of the role to create + * @return new builder instance + */ + public static CqlRoleBuilder createRole(String roleName) + { + return new CqlRoleBuilder(roleName); + } + + /** + * Start building a CREATE GENERATED ROLE statement. + * + * @return new builder instance + */ + public static CqlRoleBuilder createGeneratedRole() + { + return new CqlRoleBuilder(true); + } + + /** + * Whether password will be generated on server-side. + * + * @return this builder + */ + public CqlRoleBuilder generatedPassword(boolean generatedPassword) + { + this.generatePassword = generatedPassword; + this.password = null; + return this; + } + + /** + * Set the password for the role + * + * @param password the password (will be quoted in output) + * @return this builder + */ + public CqlRoleBuilder password(String password) + { + this.password = password; + this.generatePassword = false; + return this; + } + + /** + * Set whether the role can log in + * + * @param canLogin true if role can login, false otherwise + * @return this builder + */ + public CqlRoleBuilder login(boolean canLogin) + { + this.login = canLogin; + return this; + } + + /** + * Set whether the role is a superuser + * + * @param isSuperuser true if role is superuser, false otherwise + * @return this builder + */ + public CqlRoleBuilder superuser(boolean isSuperuser) + { + this.superuser = isSuperuser; + return this; + } + + /** + * Add IF NOT EXISTS clause + * + * @return this builder + */ + public CqlRoleBuilder ifNotExists() + { + this.ifNotExists = true; + return this; + } + + /** + * @param roleNameOptions options for role name generation + * @return this builder + */ + public CqlRoleBuilder option(Map roleNameOptions) + { + roleNameOptions.forEach(this::option); + return this; + } + + /** + * Add custom option with string value + * + * @param key option name + * @param value option value + * @return this builder + */ + public CqlRoleBuilder option(String key, String value) + { + this.customOptions.put(key, value); + return this; + } + + /** + * Build the final CQL CREATE ROLE statement + * + * @return the CQL statement as a string + */ + public String build() + { + if (!generateRole && roleName == null) + throw new IllegalArgumentException(""); + + if (generateRole && ifNotExists) + throw new IllegalArgumentException(""); + + StringBuilder cql = new StringBuilder(generateRole ? "CREATE GENERATED ROLE" : "CREATE ROLE"); + + if (!generateRole && ifNotExists) + cql.append(" IF NOT EXISTS "); + + if (!generateRole) + { + if (ifNotExists) + cql.append(roleName); + else + cql.append(" ").append(roleName); + } + + List withOptions = new ArrayList<>(); + + if (generatePassword) + withOptions.add("GENERATED PASSWORD"); + else if (password != null) + withOptions.add("PASSWORD = '" + password + "'"); + + if (login) + withOptions.add("LOGIN = " + login); + + if (superuser) + withOptions.add("SUPERUSER = " + superuser); + + if (generateRole && !customOptions.isEmpty()) + { + withOptions.add("OPTIONS = " + customOptions.entrySet().stream() + .map(entry -> "'" + entry.getKey() + "':'" + entry.getValue() + "'") + .collect(Collectors.joining(",", "{", "}"))); + } + + if (!withOptions.isEmpty()) + cql.append(" WITH ").append(String.join(" AND ", withOptions)); + + cql.append(";"); + + return cql.toString(); + } + } +} diff --git a/adapters/adapters-base/src/test/java/org/apache/cassandra/sidecar/adapters/base/CqlRoleBuilderTest.java b/adapters/adapters-base/src/test/java/org/apache/cassandra/sidecar/adapters/base/CqlRoleBuilderTest.java new file mode 100644 index 000000000..1bbb4f9a7 --- /dev/null +++ b/adapters/adapters-base/src/test/java/org/apache/cassandra/sidecar/adapters/base/CqlRoleBuilderTest.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.adapters.base; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.apache.cassandra.sidecar.adapters.base.CassandraRolesOperations.CqlRoleBuilder.createGeneratedRole; +import static org.apache.cassandra.sidecar.adapters.base.CassandraRolesOperations.CqlRoleBuilder.createRole; +import static org.apache.cassandra.sidecar.adapters.base.CassandraRolesOperations.UUIDGenerator.NAME_PREFIX_KEY; +import static org.apache.cassandra.sidecar.adapters.base.CassandraRolesOperations.UUIDGenerator.NAME_SIZE; +import static org.apache.cassandra.sidecar.adapters.base.CassandraRolesOperations.UUIDGenerator.NAME_SUFFIX_KEY; +import static org.assertj.core.api.Assertions.assertThat; + +public class CqlRoleBuilderTest +{ + @Test + public void testBuilder() + { + assertThat(createGeneratedRole().build()) + .isEqualTo("CREATE GENERATED ROLE;"); + + assertThat(createGeneratedRole().login(true).build()) + .isEqualTo("CREATE GENERATED ROLE WITH LOGIN = true;"); + + assertThat(createGeneratedRole().superuser(true).build()) + .isEqualTo("CREATE GENERATED ROLE WITH SUPERUSER = true;"); + + assertThat(createGeneratedRole().login(true).superuser(true).build()) + .isEqualTo("CREATE GENERATED ROLE WITH LOGIN = true AND SUPERUSER = true;"); + assertThat(createGeneratedRole().generatedPassword(true).build()) + .isEqualTo("CREATE GENERATED ROLE WITH GENERATED PASSWORD;"); + + assertThat(createGeneratedRole().password("123").build()) + .isEqualTo("CREATE GENERATED ROLE WITH PASSWORD = '123';"); + + Map optionsMap = new LinkedHashMap<>(); + optionsMap.put(NAME_PREFIX_KEY, "prefix_"); + optionsMap.put(NAME_SUFFIX_KEY, "_suffix"); + optionsMap.put(NAME_SIZE, "10"); + + assertThat(createGeneratedRole().password("123").option(optionsMap).build()) + .isEqualTo("CREATE GENERATED ROLE WITH PASSWORD = '123' AND OPTIONS = {'" + + NAME_PREFIX_KEY + "':'prefix_','" + + NAME_SUFFIX_KEY + "':'_suffix','" + + NAME_SIZE + "':'10'};"); + + assertThat(createRole("john").password("123").build()) + .isEqualTo("CREATE ROLE john WITH PASSWORD = '123';"); + + assertThat(createRole("john").password("123").login(true).superuser(true).build()) + .isEqualTo("CREATE ROLE john WITH PASSWORD = '123' AND LOGIN = true AND SUPERUSER = true;"); + + assertThat(createRole("john").generatedPassword(true).login(true).superuser(true).build()) + .isEqualTo("CREATE ROLE john WITH GENERATED PASSWORD AND LOGIN = true AND SUPERUSER = true;"); + + // specific password mentioned later will reset generated password flag + assertThat(createRole("john").generatedPassword(true).password("123").login(true).superuser(true).build()) + .isEqualTo("CREATE ROLE john WITH PASSWORD = '123' AND LOGIN = true AND SUPERUSER = true;"); + + assertThat(createRole("john").password("123").generatedPassword(true).login(true).superuser(true).build()) + .isEqualTo("CREATE ROLE john WITH GENERATED PASSWORD AND LOGIN = true AND SUPERUSER = true;"); + } +} \ No newline at end of file diff --git a/client-common/src/main/java/org/apache/cassandra/sidecar/common/ApiEndpointsV1.java b/client-common/src/main/java/org/apache/cassandra/sidecar/common/ApiEndpointsV1.java index 52bce4a04..577c45ad9 100644 --- a/client-common/src/main/java/org/apache/cassandra/sidecar/common/ApiEndpointsV1.java +++ b/client-common/src/main/java/org/apache/cassandra/sidecar/common/ApiEndpointsV1.java @@ -161,6 +161,8 @@ public final class ApiEndpointsV1 public static final String LIVE_MIGRATION_DATA_COPY_TASKS_ROUTE = LIVE_MIGRATION_API_PREFIX + "/data-copy-tasks"; public static final String LIVE_MIGRATION_DATA_COPY_TASK_ROUTE = LIVE_MIGRATION_DATA_COPY_TASKS_ROUTE + "/:taskId"; + public static final String GENERATE_ROLE = API_V1 + CASSANDRA + "/generate-role"; + public static final String OPENAPI_JSON_ROUTE = "/spec/openapi.json"; public static final String OPENAPI_YAML_ROUTE = "/spec/openapi.yaml"; // With the wildcard, the index.html page under resources/docs/openapi/index.html will render diff --git a/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/GenerateRoleRequest.java b/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/GenerateRoleRequest.java new file mode 100644 index 000000000..91d61da73 --- /dev/null +++ b/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/GenerateRoleRequest.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.common.request; + +import io.netty.handler.codec.http.HttpMethod; +import org.apache.cassandra.sidecar.common.request.data.GenerateRoleRequestPayload; +import org.apache.cassandra.sidecar.common.response.GenerateRoleResponse; + +import static org.apache.cassandra.sidecar.common.ApiEndpointsV1.GENERATE_ROLE; + +/** + * Server-side role generation needs CEP-55. If executed against older nodes, + * then client-side generation has to be used. + */ +public class GenerateRoleRequest extends JsonRequest +{ + private final GenerateRoleRequestPayload payload; + + /** + * Constructs a request to generate a role. + * + * @param payload payload with generation parameters + */ + public GenerateRoleRequest(GenerateRoleRequestPayload payload) + { + super(GENERATE_ROLE); + this.payload = payload; + } + + public HttpMethod method() + { + return HttpMethod.PUT; + } + + public GenerateRoleRequestPayload requestBody() + { + return payload; + } +} diff --git a/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/data/GenerateRoleRequestPayload.java b/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/data/GenerateRoleRequestPayload.java new file mode 100644 index 000000000..4b04eb952 --- /dev/null +++ b/client-common/src/main/java/org/apache/cassandra/sidecar/common/request/data/GenerateRoleRequestPayload.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.common.request.data; + +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class GenerateRoleRequestPayload +{ + public static final String ROLE_NAME_OPTIONS_KEY_NAME = "role_name_options"; + public static final String SIDECAR_GENERATION_KEY_NAME = "sidecar_generation"; + public static final String PASSWORD_GENERATION_KEY_NAME = "password_generation"; + public static final String LOGIN_KEY_NAME = "login"; + public static final String SUPERUSER_KEY_NAME = "superuser"; + + @JsonProperty(ROLE_NAME_OPTIONS_KEY_NAME) + public final Map roleNameOptions; + + @JsonProperty(SIDECAR_GENERATION_KEY_NAME) + public final boolean sidecarGeneration; + + @JsonProperty(PASSWORD_GENERATION_KEY_NAME) + public final boolean passwordGeneration; + + @JsonProperty(LOGIN_KEY_NAME) + public final boolean login; + + @JsonProperty(SUPERUSER_KEY_NAME) + public final boolean superuser; + + @JsonCreator + public GenerateRoleRequestPayload(Map config, + boolean sidecarGeneration, + boolean passwordGeneration, + boolean login, + boolean superuser) + { + this.roleNameOptions = config; + this.sidecarGeneration = sidecarGeneration; + this.passwordGeneration = passwordGeneration; + this.login = login; + this.superuser = superuser; + } + + public Map roleNameOptions() + { + return roleNameOptions; + } + + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GenerateRoleRequestPayload payload = (GenerateRoleRequestPayload) o; + return sidecarGeneration == payload.sidecarGeneration + && passwordGeneration == payload.passwordGeneration + && login == payload.login + && superuser == payload.superuser + && Objects.equals(roleNameOptions, payload.roleNameOptions); + } + + public int hashCode() + { + return Objects.hash(roleNameOptions, sidecarGeneration, passwordGeneration, login, superuser); + } + + public String toString() + { + return "GenerateRoleRequestPayload{" + + "roleNameOptions=" + roleNameOptions + + ", sidecarGeneration=" + sidecarGeneration + + ", passwordGeneration=" + passwordGeneration + + ", login=" + login + + ", superuser=" + superuser + + '}'; + } +} diff --git a/client-common/src/main/java/org/apache/cassandra/sidecar/common/response/GenerateRoleResponse.java b/client-common/src/main/java/org/apache/cassandra/sidecar/common/response/GenerateRoleResponse.java new file mode 100644 index 000000000..485a697ee --- /dev/null +++ b/client-common/src/main/java/org/apache/cassandra/sidecar/common/response/GenerateRoleResponse.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.common.response; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jetbrains.annotations.NotNull; + +public class GenerateRoleResponse +{ + public static final String GENERATED_ROLE_KEY = "generated_role"; + public static final String GENERATED_PASSWORD_KEY = "generated_password"; + + @NotNull + private final String role; + + private final String password; + + /** + * Constructs a {@link GenerateRoleResponse} object with the given {@code role} and {@code password}. + * + * @param role generated role + * @param password generated password, null if not generated + */ + public GenerateRoleResponse(@JsonProperty(GENERATED_ROLE_KEY) String role, + @JsonProperty(GENERATED_PASSWORD_KEY) String password) + { + this.role = Objects.requireNonNull(role, "role must not be null"); + this.password = password; + } + + @JsonProperty(GENERATED_ROLE_KEY) + public String role() + { + return role; + } + + @JsonProperty(GENERATED_PASSWORD_KEY) + @JsonInclude(JsonInclude.Include.NON_NULL) + public String password() + { + return password; + } + + /** + * @return whether the generated password is present + */ + @JsonIgnore + public boolean isGeneratedPassword() + { + return password != null; + } + + public boolean equals(Object o) + { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GenerateRoleResponse response = (GenerateRoleResponse) o; + return Objects.equals(role, response.role) && Objects.equals(password, response.password); + } + + public int hashCode() + { + return Objects.hash(role, password); + } + + public String toString() + { + return "GenerateRoleResponse{" + + "role='" + role + '\'' + + ", password='" + + '}'; + } +} diff --git a/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java index 5cf1399c8..760e82cac 100644 --- a/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java +++ b/client/src/main/java/org/apache/cassandra/sidecar/client/SidecarClient.java @@ -19,6 +19,7 @@ package org.apache.cassandra.sidecar.client; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -42,6 +43,7 @@ import org.apache.cassandra.sidecar.common.request.CreateRestoreJobRequest; import org.apache.cassandra.sidecar.common.request.CreateRestoreJobSliceRequest; import org.apache.cassandra.sidecar.common.request.DeleteServiceConfigRequest; +import org.apache.cassandra.sidecar.common.request.GenerateRoleRequest; import org.apache.cassandra.sidecar.common.request.ImportSSTableRequest; import org.apache.cassandra.sidecar.common.request.ListCdcSegmentsRequest; import org.apache.cassandra.sidecar.common.request.LiveMigrationListInstanceFilesRequest; @@ -57,12 +59,14 @@ import org.apache.cassandra.sidecar.common.request.data.CreateRestoreJobRequestPayload; import org.apache.cassandra.sidecar.common.request.data.CreateSliceRequestPayload; import org.apache.cassandra.sidecar.common.request.data.Digest; +import org.apache.cassandra.sidecar.common.request.data.GenerateRoleRequestPayload; import org.apache.cassandra.sidecar.common.request.data.NodeCommandRequestPayload; import org.apache.cassandra.sidecar.common.request.data.RestoreJobProgressRequestParams; import org.apache.cassandra.sidecar.common.request.data.UpdateCdcServiceConfigPayload; import org.apache.cassandra.sidecar.common.request.data.UpdateRestoreJobRequestPayload; import org.apache.cassandra.sidecar.common.response.CompactionStatsResponse; import org.apache.cassandra.sidecar.common.response.ConnectedClientStatsResponse; +import org.apache.cassandra.sidecar.common.response.GenerateRoleResponse; import org.apache.cassandra.sidecar.common.response.GossipInfoResponse; import org.apache.cassandra.sidecar.common.response.HealthResponse; import org.apache.cassandra.sidecar.common.response.InstanceFilesListResponse; @@ -940,6 +944,12 @@ public CompletableFuture liveMigrationStreamFileAsync(SidecarInstance inst .build()); } + public CompletableFuture generateRole(SidecarInstance instance, GenerateRoleRequestPayload payload) + { + return executor.executeRequestAsync(requestBuilder().singleInstanceSelectionPolicy(instance) + .request(new GenerateRoleRequest(payload)).build()); + } + /** * {@inheritDoc} */ diff --git a/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/ICassandraAdapter.java b/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/ICassandraAdapter.java index c18ee91ed..bc7c86d68 100644 --- a/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/ICassandraAdapter.java +++ b/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/ICassandraAdapter.java @@ -101,6 +101,12 @@ default ResultSet executeLocal(String query) throws CassandraUnavailableExceptio */ @NotNull MetricsOperations metricsOperations() throws CassandraUnavailableException; + /** + * @return the {@link RolesOperations} implementation of the Cassandra cluster + * @throws CassandraUnavailableException when Cassandra is not available + */ + @NotNull RolesOperations rolesOperations() throws CassandraUnavailableException; + /** * @return the {@link ClusterMembershipOperations} implementation for handling cluster membership operations * @throws CassandraUnavailableException when Cassandra is not available diff --git a/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/RolesOperations.java b/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/RolesOperations.java new file mode 100644 index 000000000..72bbc7426 --- /dev/null +++ b/server-common/src/main/java/org/apache/cassandra/sidecar/common/server/RolesOperations.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.common.server; + +import org.apache.cassandra.sidecar.common.request.data.GenerateRoleRequestPayload; +import org.apache.cassandra.sidecar.common.response.GenerateRoleResponse; + +/** + * An interface gathering all operations related to Cassandra roles management. + */ +public interface RolesOperations +{ + /** + * Generates a role and create such a role in Cassandra. + * Generation can occur in Sidecar or delegated to Cassandra. + * + * @param payload payload specifying generation details + * @return response containing generated role name and optionally generated password + */ + GenerateRoleResponse generateRole(GenerateRoleRequestPayload payload); +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/BasicPermissions.java b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/BasicPermissions.java index 0a809b1be..16fc2331c 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/BasicPermissions.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/acl/authorization/BasicPermissions.java @@ -84,4 +84,7 @@ public class BasicPermissions // Live Migration permissions public static final Permission LIST_FILES = new DomainAwarePermission("LIVE_MIGRATION:LIST_FILES", CLUSTER_SCOPE); public static final Permission STREAM_FILES = new DomainAwarePermission("LIVE_MIGRATION:STREAM", CLUSTER_SCOPE); + + // Role management + public static final Permission ROLE_CREATION = new DomainAwarePermission("ROLE:CREATE", CLUSTER_SCOPE); } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/cluster/CassandraAdapterDelegate.java b/server/src/main/java/org/apache/cassandra/sidecar/cluster/CassandraAdapterDelegate.java index 4b5655bf1..337b2ccc9 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/cluster/CassandraAdapterDelegate.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/cluster/CassandraAdapterDelegate.java @@ -53,6 +53,7 @@ import org.apache.cassandra.sidecar.common.server.ICassandraAdapter; import org.apache.cassandra.sidecar.common.server.JmxClient; import org.apache.cassandra.sidecar.common.server.MetricsOperations; +import org.apache.cassandra.sidecar.common.server.RolesOperations; import org.apache.cassandra.sidecar.common.server.StorageOperations; import org.apache.cassandra.sidecar.common.server.TableOperations; import org.apache.cassandra.sidecar.common.server.utils.DriverUtils; @@ -415,6 +416,13 @@ public MetricsOperations metricsOperations() throws CassandraUnavailableExceptio return fromAdapter(ICassandraAdapter::metricsOperations); } + @Override + @NotNull + public RolesOperations rolesOperations() throws CassandraUnavailableException + { + return fromAdapter(ICassandraAdapter::rolesOperations); + } + @Override @NotNull public ClusterMembershipOperations clusterMembershipOperations() throws CassandraUnavailableException diff --git a/server/src/main/java/org/apache/cassandra/sidecar/handlers/role/RoleGenerationHandler.java b/server/src/main/java/org/apache/cassandra/sidecar/handlers/role/RoleGenerationHandler.java new file mode 100644 index 000000000..a24e6fd8f --- /dev/null +++ b/server/src/main/java/org/apache/cassandra/sidecar/handlers/role/RoleGenerationHandler.java @@ -0,0 +1,176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.handlers.role; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +import org.apache.commons.lang3.StringUtils; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.json.JsonObject; +import io.vertx.core.net.SocketAddress; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.web.RoutingContext; +import org.apache.cassandra.sidecar.acl.authorization.BasicPermissions; +import org.apache.cassandra.sidecar.adapters.base.exception.OperationUnavailableException; +import org.apache.cassandra.sidecar.cluster.CassandraAdapterDelegate; +import org.apache.cassandra.sidecar.common.request.GenerateRoleRequest; +import org.apache.cassandra.sidecar.common.request.data.GenerateRoleRequestPayload; +import org.apache.cassandra.sidecar.concurrent.ExecutorPools; +import org.apache.cassandra.sidecar.handlers.AbstractHandler; +import org.apache.cassandra.sidecar.handlers.AccessProtected; +import org.apache.cassandra.sidecar.utils.CassandraInputValidator; +import org.apache.cassandra.sidecar.utils.InstanceMetadataFetcher; +import org.jetbrains.annotations.NotNull; + +import static java.util.stream.Collectors.toMap; +import static org.apache.cassandra.sidecar.common.request.data.GenerateRoleRequestPayload.PASSWORD_GENERATION_KEY_NAME; +import static org.apache.cassandra.sidecar.common.request.data.GenerateRoleRequestPayload.ROLE_NAME_OPTIONS_KEY_NAME; + +@Singleton +public class RoleGenerationHandler extends AbstractHandler implements AccessProtected +{ + private static final ParameterPredicate PAYLOAD_PARAMETER_ACCEPTANCE_PREDICATE = new ParameterPredicate(); + + /** + * Constructs a handler with the provided {@code metadataFetcher} + * + * @param metadataFetcher the interface to retrieve instance metadata + * @param executorPools the executor pools for blocking executions + * @param validator a validator instance to validate Cassandra-specific input + */ + @Inject + public RoleGenerationHandler(InstanceMetadataFetcher metadataFetcher, ExecutorPools executorPools, CassandraInputValidator validator) + { + super(metadataFetcher, executorPools, validator); + } + + @Override + protected void handleInternal(RoutingContext context, + HttpServerRequest httpRequest, + @NotNull String host, + SocketAddress remoteAddress, + GenerateRoleRequest request) + { + executorPools.service() + .executeBlocking(() -> + { + CassandraAdapterDelegate delegate = metadataFetcher.delegate(host); + + if (delegate.version().major < 5 && !request.requestBody().sidecarGeneration) + throw new OperationUnavailableException("Cassandra node has to be at least 6 to execute server-side generation."); + + return delegate.rolesOperations().generateRole(request.requestBody()); + }) + .onSuccess(context::json) + .onFailure(cause -> processFailure(cause, context, host, remoteAddress, request)); + } + + @Override + protected GenerateRoleRequest extractParamsOrThrow(RoutingContext context) + { + boolean passwordGeneration = false; + boolean sidecarGeneration = false; + boolean login = false; + boolean superuser = false; + Map config = new HashMap<>(); + + if (context.body() != null) + { + JsonObject jsonObject = context.body().asJsonObject(); + if (jsonObject != null) + { + JsonObject configObject = jsonObject.getJsonObject(ROLE_NAME_OPTIONS_KEY_NAME); + if (configObject != null) + { + config = configObject.getMap() + .entrySet() + .stream() + .filter(PAYLOAD_PARAMETER_ACCEPTANCE_PREDICATE) + .collect(toMap(Map.Entry::getKey, e -> (String) e.getValue())); + } + + if (jsonObject.containsKey(PASSWORD_GENERATION_KEY_NAME)) + passwordGeneration = jsonObject.getBoolean(GenerateRoleRequestPayload.PASSWORD_GENERATION_KEY_NAME); + + if (jsonObject.containsKey(GenerateRoleRequestPayload.SIDECAR_GENERATION_KEY_NAME)) + sidecarGeneration = jsonObject.getBoolean(GenerateRoleRequestPayload.SIDECAR_GENERATION_KEY_NAME); + + if (jsonObject.containsKey(GenerateRoleRequestPayload.LOGIN_KEY_NAME)) + login = jsonObject.getBoolean(GenerateRoleRequestPayload.LOGIN_KEY_NAME); + + if (jsonObject.containsKey(GenerateRoleRequestPayload.SUPERUSER_KEY_NAME)) + superuser = jsonObject.getBoolean(GenerateRoleRequestPayload.SUPERUSER_KEY_NAME); + } + } + + return new GenerateRoleRequest(new GenerateRoleRequestPayload(config, + sidecarGeneration, + passwordGeneration, + login, + superuser)); + } + + @Override + public Set requiredAuthorizations() + { + return Collections.singleton(BasicPermissions.ROLE_CREATION.toAuthorization()); + } + + private static class ParameterPredicate implements Predicate> + { + public boolean test(Map.Entry entry) + { + String key = entry.getKey(); + Object value = entry.getValue(); + if (!(value instanceof String)) + return false; + String valueString = (String) value; + + // underscores are allowed either in names of values but nothing else + String keyWithoutUnderscore = key.replaceAll("_", ""); + + // key can not be numeric + if (!StringUtils.isAlpha(keyWithoutUnderscore)) + return false; + + String valueWithoutUnderscore = valueString.replaceAll("_", ""); + return StringUtils.isAlpha(valueWithoutUnderscore) || isInteger(valueString); + } + + private boolean isInteger(String value) + { + try + { + Integer.parseInt(value); + return true; + } + catch (Throwable t) + { + return false; + } + } + } +} diff --git a/server/src/main/java/org/apache/cassandra/sidecar/modules/CassandraOperationsModule.java b/server/src/main/java/org/apache/cassandra/sidecar/modules/CassandraOperationsModule.java index 9b1278a88..73c841909 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/modules/CassandraOperationsModule.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/modules/CassandraOperationsModule.java @@ -27,6 +27,7 @@ import org.apache.cassandra.sidecar.common.ApiEndpointsV1; import org.apache.cassandra.sidecar.common.response.CompactionStatsResponse; import org.apache.cassandra.sidecar.common.response.ConnectedClientStatsResponse; +import org.apache.cassandra.sidecar.common.response.GenerateRoleResponse; import org.apache.cassandra.sidecar.common.response.GossipInfoResponse; import org.apache.cassandra.sidecar.common.response.ListOperationalJobsResponse; import org.apache.cassandra.sidecar.common.response.OperationalJobResponse; @@ -52,6 +53,7 @@ import org.apache.cassandra.sidecar.handlers.TableStatsHandler; import org.apache.cassandra.sidecar.handlers.TokenRangeReplicaMapHandler; import org.apache.cassandra.sidecar.handlers.cassandra.NodeSettingsHandler; +import org.apache.cassandra.sidecar.handlers.role.RoleGenerationHandler; import org.apache.cassandra.sidecar.handlers.validations.ValidateTableExistenceHandler; import org.apache.cassandra.sidecar.modules.multibindings.KeyClassMapKey; import org.apache.cassandra.sidecar.modules.multibindings.TableSchemaMapKeys; @@ -379,4 +381,20 @@ VertxRoute cassandraChangeNativeStateRoute(RouteBuilder.Factory factory, .handler(nodeNativeHandler) .build(); } + + @PUT + @Path(ApiEndpointsV1.GENERATE_ROLE) + @Operation(summary = "Update service configuration", + description = "Generates a role with optionally generated password") + @APIResponse(description = "Role has been generated successfully", + responseCode = "200", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = GenerateRoleResponse.class))) + @ProvidesIntoMap + @KeyClassMapKey(VertxRouteMapKeys.GenerateRoleKey.class) + VertxRoute createGeneratedRoleRoute(RouteBuilder.Factory factory, + RoleGenerationHandler roleGenerationHandler) + { + return factory.builderForRoute().setBodyHandler(true).handler(roleGenerationHandler).build(); + } } diff --git a/server/src/main/java/org/apache/cassandra/sidecar/modules/multibindings/VertxRouteMapKeys.java b/server/src/main/java/org/apache/cassandra/sidecar/modules/multibindings/VertxRouteMapKeys.java index 48b1e38fa..f35dc8f2c 100644 --- a/server/src/main/java/org/apache/cassandra/sidecar/modules/multibindings/VertxRouteMapKeys.java +++ b/server/src/main/java/org/apache/cassandra/sidecar/modules/multibindings/VertxRouteMapKeys.java @@ -243,6 +243,11 @@ interface LiveMigrationListInstanceFilesRouteKey extends RouteClassKey HttpMethod HTTP_METHOD = HttpMethod.GET; String ROUTE_URI = ApiEndpointsV1.LIVE_MIGRATION_FILES_ROUTE; } + interface GenerateRoleKey extends RouteClassKey + { + HttpMethod HTTP_METHOD = HttpMethod.PUT; + String ROUTE_URI = ApiEndpointsV1.GENERATE_ROLE; + } interface SSTableCleanupRouteKey extends RouteClassKey { HttpMethod HTTP_METHOD = HttpMethod.DELETE; diff --git a/server/src/test/integration/org/apache/cassandra/sidecar/routes/RoleGenerationHandlerIntegrationTest.java b/server/src/test/integration/org/apache/cassandra/sidecar/routes/RoleGenerationHandlerIntegrationTest.java new file mode 100644 index 000000000..570acc6eb --- /dev/null +++ b/server/src/test/integration/org/apache/cassandra/sidecar/routes/RoleGenerationHandlerIntegrationTest.java @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.sidecar.routes; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; + +import com.datastax.driver.core.Session; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.codec.BodyCodec; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import org.apache.cassandra.sidecar.common.ApiEndpointsV1; +import org.apache.cassandra.sidecar.common.request.data.GenerateRoleRequestPayload; +import org.apache.cassandra.sidecar.common.response.GenerateRoleResponse; +import org.apache.cassandra.sidecar.testing.IntegrationTestBase; +import org.apache.cassandra.testing.AuthMode; +import org.apache.cassandra.testing.CassandraIntegrationTest; + +import static org.apache.cassandra.sidecar.adapters.base.CassandraRolesOperations.UUIDGenerator.NAME_PREFIX_KEY; +import static org.apache.cassandra.sidecar.adapters.base.CassandraRolesOperations.UUIDGenerator.NAME_SUFFIX_KEY; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(VertxExtension.class) +public class RoleGenerationHandlerIntegrationTest extends IntegrationTestBase +{ + private WebClient client; + private Session session; + + @BeforeEach + public void setUp() throws Throwable + { + sidecarTestContext.setUsernamePassword("cassandra", "cassandra"); + waitForSchemaReady(30, TimeUnit.SECONDS); + session = sidecarTestContext.session(); + client = mTLSClient(); + } + + @CassandraIntegrationTest(authMode = AuthMode.PASSWORD, enableSsl = true) + public void testSidecarSideRoleGenerationWithoutGeneratedPassword(VertxTestContext context) + { + GenerateRoleRequestPayload payload = new GenerateRoleRequestPayload(Map.of(), true, false, false, false); + GenerateRoleResponse generatedRole = createGeneratedRole(client, context, payload); + + logger.info("Generated: {}", generatedRole); + + assertNotNull(generatedRole); + assertNull(generatedRole.password()); + assertFalse(generatedRole.isGeneratedPassword()); + } + + @CassandraIntegrationTest(authMode = AuthMode.PASSWORD, enableSsl = true) + public void testSidecarSideRoleGenerationWithGeneratedPassword(VertxTestContext context) + { + GenerateRoleRequestPayload payload = new GenerateRoleRequestPayload(Map.of(), true, true, false, false); + GenerateRoleResponse generatedRole = createGeneratedRole(client, context, payload); + + logger.info("Generated: {}", generatedRole); + + assertNotNull(generatedRole); + assertNotNull(generatedRole.password()); + assertTrue(generatedRole.isGeneratedPassword()); + } + + @CassandraIntegrationTest(authMode = AuthMode.PASSWORD, enableSsl = true) + public void testSidecarSideRoleGenerationWithOptions(VertxTestContext context) + { + GenerateRoleRequestPayload payload = new GenerateRoleRequestPayload(Map.of(NAME_PREFIX_KEY, "prefix_", NAME_SUFFIX_KEY, "_suffix"), true, true, false, false); + GenerateRoleResponse generatedRole = createGeneratedRole(client, context, payload); + logger.info("Generated: {}", generatedRole); + + assertNotNull(generatedRole); + assertNotNull(generatedRole.password()); + assertTrue(generatedRole.isGeneratedPassword()); + + assertTrue(generatedRole.role().startsWith("prefix_")); + assertTrue(generatedRole.role().endsWith("_suffix")); + } + + private GenerateRoleResponse createGeneratedRole(WebClient client, VertxTestContext context, GenerateRoleRequestPayload payload) + { + CompletableFuture future = new CompletableFuture<>(); + + client.put(server.actualPort(), "127.0.0.1", ApiEndpointsV1.GENERATE_ROLE) + .as(BodyCodec.json(GenerateRoleResponse.class)) + .sendJsonObject(JsonObject.mapFrom(payload)) + .map(HttpResponse::body) + .onSuccess(response -> { + assertNotNull(response); + future.complete(response); + context.completeNow(); + }) + .onFailure(context::failNow); + + try + { + return future.get(10, TimeUnit.SECONDS); + } + catch (Throwable t) + { + context.failNow(t); + return null; + } + } +}