diff --git a/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java index 678b1df161..4381359b27 100644 --- a/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/AbstractApiIntegrationTest.java @@ -245,9 +245,13 @@ protected void withUser( } } + protected String apiPathPrefix() { + return randomFrom(List.of(LEGACY_OPENDISTRO_PREFIX, PLUGINS_PREFIX)); + } + protected String securityPath(String... path) { final var fullPath = new StringJoiner("/"); - fullPath.add(randomFrom(List.of(LEGACY_OPENDISTRO_PREFIX, PLUGINS_PREFIX))); + fullPath.add(apiPathPrefix()); if (path != null) { for (final var p : path) fullPath.add(p); diff --git a/src/integrationTest/java/org/opensearch/security/api/CertificatesRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/CertificatesRestApiIntegrationTest.java new file mode 100644 index 0000000000..ebac5b80a5 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/api/CertificatesRestApiIntegrationTest.java @@ -0,0 +1,188 @@ +/* + * 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.api; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.StringJoiner; +import java.util.stream.Collectors; + +import com.carrotsearch.randomizedtesting.RandomizedContext; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.Test; + +import org.opensearch.security.dlic.rest.api.Endpoint; +import org.opensearch.security.dlic.rest.api.ssl.CertificateType; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.certificate.TestCertificates; +import org.opensearch.test.framework.cluster.LocalOpenSearchCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.security.OpenSearchSecurityPlugin.PLUGINS_PREFIX; +import static org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator.CERTS_INFO_ACTION; +import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; + +public class CertificatesRestApiIntegrationTest extends AbstractApiIntegrationTest { + + final static String REST_API_ADMIN_SSL_INFO = "rest-api-admin-ssl-info"; + + final static String REGULAR_USER = "regular_user"; + + static { + clusterSettings.put(SECURITY_RESTAPI_ADMIN_ENABLED, true); + testSecurityConfig.roles( + new TestSecurityConfig.Role("simple_user_role").clusterPermissions("cluster:admin/security/certificates/info") + ) + .rolesMapping(new TestSecurityConfig.RoleMapping("simple_user_role").users(REGULAR_USER, ADMIN_USER_NAME)) + .user(new TestSecurityConfig.User(REGULAR_USER)) + .withRestAdminUser(REST_ADMIN_USER, allRestAdminPermissions()) + .withRestAdminUser(REST_API_ADMIN_SSL_INFO, restAdminPermission(Endpoint.SSL, CERTS_INFO_ACTION)); + } + + @Override + protected String apiPathPrefix() { + return PLUGINS_PREFIX; + } + + protected String sslCertsPath(String... path) { + final var fullPath = new StringJoiner("/"); + fullPath.add(super.apiPath("certificates")); + if (path != null) { + for (final var p : path) { + fullPath.add(p); + } + } + return fullPath.toString(); + } + + @Test + public void forbiddenForRegularUser() throws Exception { + withUser(REGULAR_USER, client -> forbidden(() -> client.get(sslCertsPath()))); + } + + @Test + public void forbiddenForAdminUser() throws Exception { + withUser(ADMIN_USER_NAME, client -> forbidden(() -> client.get(sslCertsPath()))); + } + + @Test + public void availableForTlsAdmin() throws Exception { + withUser(ADMIN_USER_NAME, localCluster.getAdminCertificate(), this::verifySSLCertsInfo); + } + + @Test + public void availableForRestAdmin() throws Exception { + withUser(REST_ADMIN_USER, this::verifySSLCertsInfo); + withUser(REST_API_ADMIN_SSL_INFO, this::verifySSLCertsInfo); + } + + private void verifySSLCertsInfo(final TestRestClient client) throws Exception { + assertSSLCertsInfo( + localCluster.nodes(), + Set.of(CertificateType.HTTP, CertificateType.TRANSPORT), + ok(() -> client.get(sslCertsPath())) + ); + if (localCluster.nodes().size() > 1) { + final var randomNodes = randomNodes(); + final var nodeIds = randomNodes.stream().map(n -> n.esNode().getNodeEnvironment().nodeId()).collect(Collectors.joining(",")); + assertSSLCertsInfo( + randomNodes, + Set.of(CertificateType.HTTP, CertificateType.TRANSPORT), + ok(() -> client.get(sslCertsPath(nodeIds))) + ); + } + final var randomCertType = randomFrom(List.of(CertificateType.HTTP, CertificateType.TRANSPORT)); + assertSSLCertsInfo( + localCluster.nodes(), + Set.of(randomCertType), + ok(() -> client.get(String.format("%s?cert_type=%s", sslCertsPath(), randomCertType))) + ); + + } + + private void assertSSLCertsInfo( + final List expectedNode, + final Set expectedCertTypes, + final TestRestClient.HttpResponse response + ) { + final var body = response.bodyAsJsonNode(); + final var prettyStringBody = body.toPrettyString(); + + final var _nodes = body.get("_nodes"); + assertThat(prettyStringBody, _nodes.get("total").asInt(), is(expectedNode.size())); + assertThat(prettyStringBody, _nodes.get("successful").asInt(), is(expectedNode.size())); + assertThat(prettyStringBody, _nodes.get("failed").asInt(), is(0)); + assertThat(prettyStringBody, body.get("cluster_name").asText(), is(localCluster.getClusterName())); + + final var nodes = body.get("nodes"); + + for (final var n : expectedNode) { + final var esNode = n.esNode(); + final var node = nodes.get(esNode.getNodeEnvironment().nodeId()); + assertThat(prettyStringBody, node.get("name").asText(), is(n.getNodeName())); + assertThat(prettyStringBody, node.has("certificates")); + final var certificates = node.get("certificates"); + if (expectedCertTypes.contains(CertificateType.HTTP)) { + final var httpCertificates = certificates.get(CertificateType.HTTP.value()); + assertThat(prettyStringBody, httpCertificates.isArray()); + assertThat(prettyStringBody, httpCertificates.size(), is(1)); + verifyCertsJson(n.nodeNumber(), httpCertificates.get(0)); + } + if (expectedCertTypes.contains(CertificateType.TRANSPORT)) { + final var transportCertificates = certificates.get(CertificateType.TRANSPORT.value()); + assertThat(prettyStringBody, transportCertificates.isArray()); + assertThat(prettyStringBody, transportCertificates.size(), is(1)); + verifyCertsJson(n.nodeNumber(), transportCertificates.get(0)); + } + } + + } + + private void verifyCertsJson(final int nodeNumber, final JsonNode jsonNode) { + assertThat(jsonNode.toPrettyString(), jsonNode.get("issuer_dn").asText(), is(TestCertificates.CA_SUBJECT)); + assertThat( + jsonNode.toPrettyString(), + jsonNode.get("subject_dn").asText(), + is(String.format(TestCertificates.NODE_SUBJECT_PATTERN, nodeNumber)) + ); + assertThat( + jsonNode.toPrettyString(), + jsonNode.get("san").asText(), + containsString(String.format("node-%s.example.com", nodeNumber)) + ); + assertThat(jsonNode.toPrettyString(), jsonNode.has("not_before")); + assertThat(jsonNode.toPrettyString(), jsonNode.has("not_after")); + } + + private List randomNodes() { + final var nodes = localCluster.nodes(); + int leaveElements = randomIntBetween(1, nodes.size() - 1); + return randomSubsetOf(leaveElements, nodes); + } + + public List randomSubsetOf(int size, Collection collection) { + if (size > collection.size()) { + throw new IllegalArgumentException( + "Can't pick " + size + " random objects from a collection of " + collection.size() + " objects" + ); + } + List tempList = new ArrayList<>(collection); + Collections.shuffle(tempList, RandomizedContext.current().getRandom()); + return tempList.subList(0, size); + } + +} diff --git a/src/integrationTest/java/org/opensearch/security/api/SslCertsRestApiIntegrationTest.java b/src/integrationTest/java/org/opensearch/security/api/SslCertsRestApiIntegrationTest.java index 61085d3f8a..dbc57839b8 100644 --- a/src/integrationTest/java/org/opensearch/security/api/SslCertsRestApiIntegrationTest.java +++ b/src/integrationTest/java/org/opensearch/security/api/SslCertsRestApiIntegrationTest.java @@ -20,6 +20,7 @@ import static org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator.CERTS_INFO_ACTION; import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ADMIN_ENABLED; +@Deprecated public class SslCertsRestApiIntegrationTest extends AbstractApiIntegrationTest { final static String REST_API_ADMIN_SSL_INFO = "rest-api-admin-ssl-info"; diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalOpenSearchCluster.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalOpenSearchCluster.java index 0aee3705b5..e7b69b0ef7 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalOpenSearchCluster.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalOpenSearchCluster.java @@ -45,6 +45,7 @@ import java.util.SortedSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; @@ -163,7 +164,9 @@ public void start() throws Exception { this.initialClusterManagerHosts = toHostList(clusterManagerPorts); started = true; + final var nodeCounter = new AtomicInteger(0); CompletableFuture clusterManagerNodeFuture = startNodes( + nodeCounter, clusterManager.getClusterManagerNodeSettings(), clusterManagerNodeTransportPorts, clusterManagerNodeHttpPorts @@ -177,6 +180,7 @@ public void start() throws Exception { SortedSet nonClusterManagerNodeHttpPorts = TCP.allocate(clusterName, nonClusterManagerNodeCount, 5000 + 42 * 1000 + 210); CompletableFuture nonClusterManagerNodeFuture = startNodes( + nodeCounter, clusterManager.getNonClusterManagerNodeSettings(), nonClusterManagerNodeTransportPorts, nonClusterManagerNodeHttpPorts @@ -292,6 +296,7 @@ private final Node findRunningNode(List nodes, List... moreNodes) { } private CompletableFuture startNodes( + AtomicInteger nodeCounter, List nodeSettingList, SortedSet transportPorts, SortedSet httpPorts @@ -300,8 +305,8 @@ private CompletableFuture startNodes( Iterator httpPortIterator = httpPorts.iterator(); List> futures = new ArrayList<>(); - for (var i = 0; i < nodeSettingList.size(); i++) { - Node node = new Node(i, nodeSettingList.get(i), transportPortIterator.next(), httpPortIterator.next()); + for (final var nodeSettings : nodeSettingList) { + Node node = new Node(nodeCounter.getAndIncrement(), nodeSettings, transportPortIterator.next(), httpPortIterator.next()); futures.add(node.start()); } return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); @@ -385,6 +390,10 @@ public class Node implements OpenSearchClientProvider { private boolean portCollision = false; private final int nodeNumber; + boolean hasAssignedType(NodeType type) { + return requireNonNull(type, "Node type is required.").equals(this.nodeType); + } + Node(int nodeNumber, NodeSettings nodeSettings, int transportPort, int httpPort) { this.nodeNumber = nodeNumber; this.nodeName = createNextNodeName(requireNonNull(nodeSettings, "Node settings are required.")); @@ -402,8 +411,8 @@ public class Node implements OpenSearchClientProvider { nodes.add(this); } - boolean hasAssignedType(NodeType type) { - return requireNonNull(type, "Node type is required.").equals(this.nodeType); + public int nodeNumber() { + return nodeNumber; } CompletableFuture start() { diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 4a33d685e9..36ef7709e8 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -155,6 +155,8 @@ import org.opensearch.security.configuration.SecurityFlsDlsIndexSearcherWrapper; import org.opensearch.security.dlic.rest.api.Endpoint; import org.opensearch.security.dlic.rest.api.SecurityRestApiActions; +import org.opensearch.security.dlic.rest.api.ssl.CertificatesActionType; +import org.opensearch.security.dlic.rest.api.ssl.TransportCertificatesInfoNodesAction; import org.opensearch.security.dlic.rest.validation.PasswordValidator; import org.opensearch.security.filter.SecurityFilter; import org.opensearch.security.filter.SecurityRestFilter; @@ -174,6 +176,7 @@ import org.opensearch.security.securityconf.DynamicConfigFactory; import org.opensearch.security.setting.OpensearchDynamicSetting; import org.opensearch.security.setting.TransportPassiveAuthSetting; +import org.opensearch.security.ssl.ExternalSecurityKeyStore; import org.opensearch.security.ssl.OpenSearchSecureSettingsFactory; import org.opensearch.security.ssl.OpenSearchSecuritySSLPlugin; import org.opensearch.security.ssl.SslExceptionHandler; @@ -670,6 +673,10 @@ public UnaryOperator getRestHandlerWrapper(final ThreadContext thre List> actions = new ArrayList<>(1); if (!disabled && !SSLConfig.isSslOnlyMode()) { actions.add(new ActionHandler<>(ConfigUpdateAction.INSTANCE, TransportConfigUpdateAction.class)); + // external storage does not support reload and does not provide SSL certs info + if (!ExternalSecurityKeyStore.hasExternalSslContext(settings)) { + actions.add(new ActionHandler<>(CertificatesActionType.INSTANCE, TransportCertificatesInfoNodesAction.class)); + } actions.add(new ActionHandler<>(WhoAmIAction.INSTANCE, TransportWhoAmIAction.class)); } return actions; @@ -1193,6 +1200,9 @@ public Collection createComponents( components.add(si); components.add(dcf); components.add(userService); + if (!ExternalSecurityKeyStore.hasExternalSslContext(settings)) { + components.add(sks); + } final var allowDefaultInit = settings.getAsBoolean(SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, false); final var useClusterState = useClusterStateToInitSecurityConfig(settings); if (!SSLConfig.isSslOnlyMode() && !isDisabled(settings) && allowDefaultInit && useClusterState) { diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/CertificatesApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/CertificatesApiAction.java new file mode 100644 index 0000000000..ea045c3aef --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/CertificatesApiAction.java @@ -0,0 +1,120 @@ +/* + * 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.List; + +import com.google.common.collect.ImmutableList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionListener; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestActions; +import org.opensearch.security.dlic.rest.api.ssl.CertificateType; +import org.opensearch.security.dlic.rest.api.ssl.CertificatesActionType; +import org.opensearch.security.dlic.rest.api.ssl.CertificatesInfoNodesRequest; +import org.opensearch.security.dlic.rest.api.ssl.CertificatesNodesResponse; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.threadpool.ThreadPool; + +import static org.opensearch.security.dlic.rest.api.Responses.internalServerError; +import static org.opensearch.security.dlic.rest.api.Responses.ok; +import static org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator.CERTS_INFO_ACTION; +import static org.opensearch.security.dlic.rest.support.Utils.PLUGIN_API_ROUTE_PREFIX; +import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; + +public class CertificatesApiAction extends AbstractApiAction { + + private final static Logger LOGGER = LogManager.getLogger(CertificatesApiAction.class); + + private static final List ROUTES = addRoutesPrefix( + ImmutableList.of(new Route(RestRequest.Method.GET, "/certificates"), new Route(RestRequest.Method.GET, "/certificates/{nodeId}")), + PLUGIN_API_ROUTE_PREFIX + ); + + public CertificatesApiAction( + final ClusterService clusterService, + final ThreadPool threadPool, + final SecurityApiDependencies securityApiDependencies + ) { + super(Endpoint.SSL, clusterService, threadPool, securityApiDependencies); + this.requestHandlersBuilder.configureRequestHandlers(this::securitySSLCertsRequestHandlers); + } + + @Override + public List routes() { + return ROUTES; + } + + @Override + public String getName() { + return "HTTP and Transport Certificates Actions"; + } + + @Override + protected CType getConfigType() { + return null; + } + + @Override + protected void consumeParameters(RestRequest request) { + request.param("nodeId"); + request.param("cert_type"); + } + + private void securitySSLCertsRequestHandlers(RequestHandler.RequestHandlersBuilder requestHandlersBuilder) { + requestHandlersBuilder.withAccessHandler(this::accessHandler) + .allMethodsNotImplemented() + .verifyAccessForAllMethods() + .override( + RestRequest.Method.GET, + (channel, request, client) -> client.execute( + CertificatesActionType.INSTANCE, + new CertificatesInfoNodesRequest( + CertificateType.from(request.param("cert_type")), + true, + request.paramAsStringArrayOrEmptyIfAll("nodeId") + ).timeout(request.param("timeout")), + new ActionListener<>() { + @Override + public void onResponse(final CertificatesNodesResponse response) { + ok(channel, (builder, params) -> { + builder.startObject(); + RestActions.buildNodesHeader(builder, channel.request(), response); + builder.field("cluster_name", response.getClusterName().value()); + response.toXContent(builder, channel.request()); + builder.endObject(); + return builder; + }); + } + + @Override + public void onFailure(Exception e) { + LOGGER.error("Cannot load SSL certificates info due to", e); + internalServerError(channel, "Cannot load SSL certificates info " + e.getMessage() + "."); + } + } + ) + ); + } + + boolean accessHandler(final RestRequest request) { + if (request.method() == RestRequest.Method.GET) { + return securityApiDependencies.restApiAdminPrivilegesEvaluator().isCurrentUserAdminFor(endpoint, CERTS_INFO_ACTION); + } else { + return false; + } + } + +} 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 f38cf0580d..4344c85b09 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 @@ -95,8 +95,9 @@ public static Collection getHandler( new AllowlistApiAction(Endpoint.ALLOWLIST, clusterService, threadPool, securityApiDependencies), new AuditApiAction(clusterService, threadPool, securityApiDependencies), new MultiTenancyConfigApiAction(clusterService, threadPool, securityApiDependencies), + new ConfigUpgradeApiAction(clusterService, threadPool, securityApiDependencies), new SecuritySSLCertsApiAction(clusterService, threadPool, securityKeyStore, certificatesReloadEnabled, securityApiDependencies), - new ConfigUpgradeApiAction(clusterService, threadPool, securityApiDependencies) + new CertificatesApiAction(clusterService, threadPool, securityApiDependencies) ); } diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/SecuritySSLCertsApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/SecuritySSLCertsApiAction.java index e60070288e..30b6c862ee 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/SecuritySSLCertsApiAction.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/SecuritySSLCertsApiAction.java @@ -20,6 +20,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.opensearch.OpenSearchSecurityException; import org.opensearch.cluster.service.ClusterService; @@ -48,9 +50,16 @@ * This action serves GET request for _plugins/_security/api/ssl/certs endpoint and * PUT _plugins/_security/api/ssl/{certType}/reloadcerts */ +@Deprecated public class SecuritySSLCertsApiAction extends AbstractApiAction { + + private final static Logger LOGGER = LogManager.getLogger(SecuritySSLCertsApiAction.class); + private static final List ROUTES = addRoutesPrefix( - ImmutableList.of(new Route(Method.GET, "/ssl/certs"), new Route(Method.PUT, "/ssl/{certType}/reloadcerts")) + ImmutableList.of( + new DeprecatedRoute(Method.GET, "/ssl/certs", "[/ssl/certs] is a deprecated endpoint. Please use [/certificates] instead."), + new Route(Method.PUT, "/ssl/{certType}/reloadcerts") + ) ); private final SecurityKeyStore securityKeyStore; diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificateInfo.java b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificateInfo.java new file mode 100644 index 0000000000..ce757286e3 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificateInfo.java @@ -0,0 +1,110 @@ +/* + * 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.ssl; + +import java.io.IOException; +import java.security.cert.X509Certificate; +import java.util.Objects; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; + +public class CertificateInfo implements Writeable, ToXContent { + + private final String subject; + + private final String san; + + private final String issuer; + + private final String notAfter; + + private final String notBefore; + + public CertificateInfo(String subject, String san, String issuer, String notAfter, String notBefore) { + this.subject = subject; + this.san = san; + this.issuer = issuer; + this.notAfter = notAfter; + this.notBefore = notBefore; + } + + public CertificateInfo(final StreamInput in) throws IOException { + this.subject = in.readOptionalString(); + this.san = in.readOptionalString(); + this.issuer = in.readOptionalString(); + this.notAfter = in.readOptionalString(); + this.notBefore = in.readOptionalString(); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeOptionalString(subject); + out.writeOptionalString(san); + out.writeOptionalString(issuer); + out.writeOptionalString(notAfter); + out.writeOptionalString(notBefore); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException { + return builder.startObject() + .field("subject_dn", subject) + .field("san", san) + .field("issuer_dn", issuer) + .field("not_after", notAfter) + .field("not_before", notAfter) + .endObject(); + } + + public static CertificateInfo from(final X509Certificate x509Certificate, final String subjectAlternativeNames) { + String subject = null; + String issuer = null; + String notAfter = null; + String notBefore = null; + if (x509Certificate != null) { + if (x509Certificate.getSubjectX500Principal() != null) { + subject = x509Certificate.getSubjectX500Principal().getName(); + } + if (x509Certificate.getIssuerX500Principal() != null) { + issuer = x509Certificate.getIssuerX500Principal().getName(); + } + if (x509Certificate.getNotAfter() != null) { + notAfter = x509Certificate.getNotAfter().toInstant().toString(); + } + if (x509Certificate.getNotBefore() != null) { + notBefore = x509Certificate.getNotBefore().toInstant().toString(); + } + } + return new CertificateInfo(subject, subjectAlternativeNames, issuer, notAfter, notBefore); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CertificateInfo that = (CertificateInfo) o; + return Objects.equals(subject, that.subject) + && Objects.equals(san, that.san) + && Objects.equals(issuer, that.issuer) + && Objects.equals(notAfter, that.notAfter) + && Objects.equals(notBefore, that.notBefore); + } + + @Override + public int hashCode() { + return Objects.hash(subject, san, issuer, notAfter, notBefore); + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificateType.java b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificateType.java new file mode 100644 index 0000000000..0158494869 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificateType.java @@ -0,0 +1,48 @@ +/* + * 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.ssl; + +import java.util.Locale; + +public enum CertificateType { + HTTP("http"), + TRANSPORT("transport"), + ALL("all"); + + private final String value; + + private CertificateType(String value) { + this.value = value; + } + + public static boolean isHttp(final CertificateType certificateType) { + return certificateType == HTTP || certificateType == ALL; + } + + public static boolean isTransport(final CertificateType certificateType) { + return certificateType == TRANSPORT || certificateType == ALL; + } + + public String value() { + return value.toLowerCase(Locale.ROOT); + } + + public static CertificateType from(final String certType) { + if (certType == null) { + return ALL; + } + for (final var t : values()) + if (t.value.equalsIgnoreCase(certType)) return t; + throw new IllegalArgumentException("Invalid certificate type: " + certType); + } + +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesActionType.java b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesActionType.java new file mode 100644 index 0000000000..e5ecef1478 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesActionType.java @@ -0,0 +1,25 @@ +/* + * 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.ssl; + +import org.opensearch.action.ActionType; + +public class CertificatesActionType extends ActionType { + + public static final CertificatesActionType INSTANCE = new CertificatesActionType(); + + public static final String NAME = "cluster:admin/security/certificates/info"; + + public CertificatesActionType() { + super(NAME, CertificatesNodesResponse::new); + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesInfo.java b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesInfo.java new file mode 100644 index 0000000000..ca15216b80 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesInfo.java @@ -0,0 +1,48 @@ +/* + * 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.ssl; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; + +public class CertificatesInfo implements Writeable, ToXContent { + + private final Map> certificates; + + public CertificatesInfo(final Map> certificates) { + this.certificates = certificates; + } + + public CertificatesInfo(final StreamInput in) throws IOException { + certificates = in.readMap(keyIn -> keyIn.readEnum(CertificateType.class), listIn -> listIn.readList(CertificateInfo::new)); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeMap(certificates, StreamOutput::writeEnum, StreamOutput::writeList); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject("certificates") + .field(CertificateType.HTTP.value(), certificates.get(CertificateType.HTTP)) + .field(CertificateType.TRANSPORT.value(), certificates.get(CertificateType.TRANSPORT)) + .endObject(); + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesInfoNodesRequest.java b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesInfoNodesRequest.java new file mode 100644 index 0000000000..f7b971daec --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesInfoNodesRequest.java @@ -0,0 +1,52 @@ +/* + * 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.ssl; + +import java.io.IOException; + +import org.opensearch.action.support.nodes.BaseNodesRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +public class CertificatesInfoNodesRequest extends BaseNodesRequest { + + private final CertificateType certificateType; + + private final boolean inMemory; + + public CertificatesInfoNodesRequest(CertificateType certificateType, boolean inMemory, String... nodesIds) { + super(nodesIds); + this.certificateType = certificateType; + this.inMemory = inMemory; + } + + public CertificatesInfoNodesRequest(final StreamInput in) throws IOException { + super(in); + certificateType = in.readEnum(CertificateType.class); + inMemory = in.readBoolean(); + } + + public CertificateType certificateType() { + return certificateType; + } + + public boolean inMemory() { + return inMemory; + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + super.writeTo(out); + out.writeEnum(certificateType); + out.writeBoolean(inMemory); + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesNodesResponse.java b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesNodesResponse.java new file mode 100644 index 0000000000..856ff0f435 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/CertificatesNodesResponse.java @@ -0,0 +1,135 @@ +/* + * 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.ssl; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +import org.opensearch.OpenSearchException; +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.nodes.BaseNodeResponse; +import org.opensearch.action.support.nodes.BaseNodesResponse; +import org.opensearch.cluster.ClusterName; +import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; + +public class CertificatesNodesResponse extends BaseNodesResponse + implements + ToXContentFragment { + + public CertificatesNodesResponse(StreamInput in) throws IOException { + super(in); + } + + public CertificatesNodesResponse(ClusterName clusterName, List nodes, List failures) { + super(clusterName, nodes, failures); + } + + @Override + protected List readNodesFrom(StreamInput in) throws IOException { + return in.readList(CertificatesNodeResponse::new); + } + + @Override + protected void writeNodesTo(StreamOutput out, List nodes) throws IOException { + out.writeList(nodes); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject("nodes"); + for (final CertificatesNodeResponse node : getNodes()) { + builder.startObject(node.getNode().getId()); + builder.field("name", node.getNode().getName()); + if (node.exception() != null) { + builder.startObject("load_exception"); + OpenSearchException.generateThrowableXContent(builder, params, node.exception); + builder.endObject(); + } + if (node.certificates() != null) { + node.certificates.toXContent(builder, params); + } + builder.endObject(); + } + builder.endObject(); + return builder; + } + + public static class CertificatesNodeResponse extends BaseNodeResponse { + + private final Exception exception; + + private final CertificatesInfo certificates; + + public CertificatesNodeResponse(final DiscoveryNode node, final Exception exception) { + super(node); + this.exception = exception; + this.certificates = null; + } + + public CertificatesNodeResponse(final DiscoveryNode node, final CertificatesInfo certificates) { + super(node); + this.exception = null; + this.certificates = certificates; + } + + public CertificatesNodeResponse(StreamInput in) throws IOException { + super(in); + if (in.readBoolean()) { + this.exception = in.readException(); + this.certificates = null; + } else { + this.exception = null; + this.certificates = in.readOptionalWriteable(CertificatesInfo::new); + } + } + + public CertificatesInfo certificates() { + return certificates; + } + + public Exception exception() { + return exception; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + if (exception != null) { + out.writeBoolean(true); + out.writeException(exception); + } + if (certificates != null) { + out.writeBoolean(false); + out.writeOptionalWriteable(certificates); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CertificatesNodeResponse that = (CertificatesNodeResponse) o; + return Objects.equals(exception, that.exception) && Objects.equals(certificates, that.certificates); + } + + @Override + public int hashCode() { + return Objects.hash(exception, certificates); + } + } + +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/ssl/TransportCertificatesInfoNodesAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/TransportCertificatesInfoNodesAction.java new file mode 100644 index 0000000000..681c2c01eb --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/ssl/TransportCertificatesInfoNodesAction.java @@ -0,0 +1,151 @@ +/* + * 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.ssl; + +import java.io.IOException; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.ImmutableList; + +import org.opensearch.action.FailedNodeException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.nodes.TransportNodesAction; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.security.ssl.DefaultSecurityKeyStore; +import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.TransportRequest; +import org.opensearch.transport.TransportService; + +public class TransportCertificatesInfoNodesAction extends TransportNodesAction< + CertificatesInfoNodesRequest, + CertificatesNodesResponse, + TransportCertificatesInfoNodesAction.NodeRequest, + CertificatesNodesResponse.CertificatesNodeResponse> { + + private final DefaultSecurityKeyStore securityKeyStore; + + private final boolean httpsEnabled; + + @Inject + public TransportCertificatesInfoNodesAction( + final Settings settings, + final ThreadPool threadPool, + final ClusterService clusterService, + final TransportService transportService, + final ActionFilters actionFilters, + final DefaultSecurityKeyStore securityKeyStore + ) { + super( + CertificatesActionType.NAME, + threadPool, + clusterService, + transportService, + actionFilters, + CertificatesInfoNodesRequest::new, + NodeRequest::new, + ThreadPool.Names.GENERIC, + CertificatesNodesResponse.CertificatesNodeResponse.class + ); + this.httpsEnabled = settings.getAsBoolean(SSLConfigConstants.SECURITY_SSL_HTTP_ENABLED, true); + this.securityKeyStore = securityKeyStore; + } + + @Override + protected CertificatesNodesResponse newResponse( + CertificatesInfoNodesRequest request, + List nodeResponses, + List failures + ) { + return new CertificatesNodesResponse(clusterService.getClusterName(), nodeResponses, failures); + } + + @Override + protected NodeRequest newNodeRequest(final CertificatesInfoNodesRequest request) { + return new NodeRequest(request); + } + + @Override + protected CertificatesNodesResponse.CertificatesNodeResponse newNodeResponse(final StreamInput in) throws IOException { + return new CertificatesNodesResponse.CertificatesNodeResponse(in); + } + + @Override + protected CertificatesNodesResponse.CertificatesNodeResponse nodeOperation(final NodeRequest request) { + final var sslCertRequest = request.sslCertsInfoNodesRequest; + + if (securityKeyStore == null) { + return new CertificatesNodesResponse.CertificatesNodeResponse( + clusterService.localNode(), + new IllegalStateException("keystore is not initialized") + ); + } + try { + return new CertificatesNodesResponse.CertificatesNodeResponse( + clusterService.localNode(), + loadCertificates(sslCertRequest.certificateType()) + ); + } catch (final Exception e) { + return new CertificatesNodesResponse.CertificatesNodeResponse(clusterService.localNode(), e); + } + } + + protected CertificatesInfo loadCertificates(final CertificateType certificateType) { + var httpCertificates = List.of(); + var transportsCertificates = List.of(); + if (CertificateType.isHttp(certificateType)) { + httpCertificates = httpsEnabled ? certificatesDetails(securityKeyStore.getHttpCerts()) : List.of(); + } + if (CertificateType.isTransport(certificateType)) { + transportsCertificates = certificatesDetails(securityKeyStore.getTransportCerts()); + } + return new CertificatesInfo(Map.of(CertificateType.HTTP, httpCertificates, CertificateType.TRANSPORT, transportsCertificates)); + } + + private List certificatesDetails(final X509Certificate[] certs) { + if (certs == null) { + return null; + } + final var certificates = ImmutableList.builder(); + for (final var c : certs) { + certificates.add(CertificateInfo.from(c, securityKeyStore.getSubjectAlternativeNames(c))); + } + return certificates.build(); + } + + public static class NodeRequest extends TransportRequest { + + CertificatesInfoNodesRequest sslCertsInfoNodesRequest; + + public NodeRequest(StreamInput in) throws IOException { + super(in); + sslCertsInfoNodesRequest = new CertificatesInfoNodesRequest(in); + } + + NodeRequest(CertificatesInfoNodesRequest request) { + this.sslCertsInfoNodesRequest = request; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + sslCertsInfoNodesRequest.writeTo(out); + } + } + +}