diff --git a/client/pom.xml b/client/pom.xml index ae0fcaa20a5b..2b52833c74af 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -414,6 +414,11 @@ cloud-plugin-database-quota ${project.version} + + org.apache.cloudstack + cloud-plugin-integrations-cloudian-connector + ${project.version} + org.apache.cloudstack cloud-plugin-integrations-prometheus-exporter diff --git a/plugins/integrations/cloudian/pom.xml b/plugins/integrations/cloudian/pom.xml new file mode 100644 index 000000000000..3e2b63562fbc --- /dev/null +++ b/plugins/integrations/cloudian/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + cloud-plugin-integrations-cloudian-connector + Apache CloudStack Plugin - Cloudian Connector + + org.apache.cloudstack + cloudstack-plugins + 4.11.0.0-SNAPSHOT + ../../pom.xml + + + + org.apache.cloudstack + cloud-api + ${project.version} + + + org.apache.cloudstack + cloud-utils + ${project.version} + + + org.apache.httpcomponents + httpclient + ${cs.httpclient.version} + + + com.fasterxml.jackson.core + jackson-databind + ${cs.jackson.version} + + + com.github.tomakehurst + wiremock + ${cs.wiremock.version} + test + + + diff --git a/plugins/integrations/cloudian/resources/META-INF/cloudstack/cloudian/module.properties b/plugins/integrations/cloudian/resources/META-INF/cloudstack/cloudian/module.properties new file mode 100644 index 000000000000..762c636cd3b7 --- /dev/null +++ b/plugins/integrations/cloudian/resources/META-INF/cloudstack/cloudian/module.properties @@ -0,0 +1,18 @@ +# 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. +name=cloudian +parent=api diff --git a/plugins/integrations/cloudian/resources/META-INF/cloudstack/cloudian/spring-cloudian-context.xml b/plugins/integrations/cloudian/resources/META-INF/cloudstack/cloudian/spring-cloudian-context.xml new file mode 100644 index 000000000000..71ed52dd7017 --- /dev/null +++ b/plugins/integrations/cloudian/resources/META-INF/cloudstack/cloudian/spring-cloudian-context.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/CloudianConnector.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/CloudianConnector.java new file mode 100644 index 000000000000..c04d70c2601d --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/CloudianConnector.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.cloudstack.cloudian; + +import org.apache.cloudstack.framework.config.ConfigKey; + +import com.cloud.utils.component.PluggableService; + +public interface CloudianConnector extends PluggableService { + + ConfigKey CloudianConnectorEnabled = new ConfigKey<>("Advanced", Boolean.class, "cloudian.connector.enabled", "false", + "If set to true, this enables the Cloudian Connector for CloudStack.", true); + + ConfigKey CloudianAdminHost = new ConfigKey<>("Advanced", String.class, "cloudian.admin.host", "s3-admin.cloudian.com", + "The hostname of the Cloudian Admin server.", true); + + ConfigKey CloudianAdminPort = new ConfigKey<>("Advanced", Integer.class, "cloudian.admin.port", "19443", + "The port of the Cloudian Admin server.", true); + + ConfigKey CloudianAdminProtocol = new ConfigKey<>("Advanced", String.class, "cloudian.admin.protocol", "https", + "The protocol of the Cloudian Admin server.", true); + + ConfigKey CloudianValidateSSLSecurity = new ConfigKey<>("Advanced", Boolean.class, "cloudian.validate.ssl", "true", + "When set to true, this will validate the SSL certificate when connecting to https/ssl enabled admin host.", true); + + ConfigKey CloudianAdminUser = new ConfigKey<>("Advanced", String.class, "cloudian.admin.user", "sysadmin", + "The system admin user for accessing the Cloudian Admin server.", true); + + ConfigKey CloudianAdminPassword = new ConfigKey<>("Advanced", String.class, "cloudian.admin.password", "public", + "The system admin password for the Cloudian Admin server.", true); + + ConfigKey CloudianAdminApiRequestTimeout = new ConfigKey<>("Advanced", Integer.class, "cloudian.api.request.timeout", "5", + "The admin API request timeout in seconds.", true); + + ConfigKey CloudianCmcAdminUser = new ConfigKey<>("Advanced", String.class, "cloudian.cmc.admin.user", "admin", + "The admin user name for accessing the Cloudian Management Console.", true); + + ConfigKey CloudianCmcHost = new ConfigKey<>("Advanced", String.class, "cloudian.cmc.host", "cmc.cloudian.com", + "The hostname of the Cloudian Management Console.", true); + + ConfigKey CloudianCmcPort = new ConfigKey<>("Advanced", String.class, "cloudian.cmc.port", "8443", + "The port of the Cloudian Management Console.", true); + + ConfigKey CloudianCmcProtocol = new ConfigKey<>("Advanced", String.class, "cloudian.cmc.protocol", "https", + "The protocol of the Cloudian Management Console.", true); + + ConfigKey CloudianSsoKey = new ConfigKey<>("Advanced", String.class, "cloudian.sso.key", "ss0sh5r3dk3y", + "The shared single sign-on key as configured in Cloudian CMC.", true); + + /** + * Returns the base Cloudian Management Console URL + * @return returns the url string + */ + String getCmcUrl(); + + /** + * Checks if the Cloudian Connector is enabled + * @return returns true is connector is enabled + */ + boolean isEnabled(); + + /** + * Generates single-sign on URL for logged in user + * @return returns the SSO URL string + */ + String generateSsoUrl(); +} diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/CloudianConnectorImpl.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/CloudianConnectorImpl.java new file mode 100644 index 000000000000..cfb23da28462 --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/CloudianConnectorImpl.java @@ -0,0 +1,345 @@ +// 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.cloudstack.cloudian; + +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.cloudian.api.CloudianIsEnabledCmd; +import org.apache.cloudstack.cloudian.api.CloudianSsoLoginCmd; +import org.apache.cloudstack.cloudian.client.CloudianClient; +import org.apache.cloudstack.cloudian.client.CloudianGroup; +import org.apache.cloudstack.cloudian.client.CloudianUser; +import org.apache.cloudstack.cloudian.client.CloudianUtils; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.framework.messagebus.MessageBus; +import org.apache.cloudstack.framework.messagebus.MessageSubscriber; +import org.apache.log4j.Logger; + +import com.cloud.domain.Domain; +import com.cloud.domain.DomainVO; +import com.cloud.domain.dao.DomainDao; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.user.DomainManager; +import com.cloud.user.User; +import com.cloud.user.dao.AccountDao; +import com.cloud.user.dao.UserDao; +import com.cloud.utils.component.ComponentLifecycleBase; +import com.cloud.utils.exception.CloudRuntimeException; + +public class CloudianConnectorImpl extends ComponentLifecycleBase implements CloudianConnector, Configurable { + private static final Logger LOG = Logger.getLogger(CloudianConnectorImpl.class); + + @Inject + private UserDao userDao; + + @Inject + private AccountDao accountDao; + + @Inject + private DomainDao domainDao; + + @Inject + private MessageBus messageBus; + + ///////////////////////////////////////////////////// + //////////////// Plugin Methods ///////////////////// + ///////////////////////////////////////////////////// + + private CloudianClient getClient() { + try { + return new CloudianClient(CloudianAdminHost.value(), CloudianAdminPort.value(), CloudianAdminProtocol.value(), + CloudianAdminUser.value(), CloudianAdminPassword.value(), + CloudianValidateSSLSecurity.value(), CloudianAdminApiRequestTimeout.value()); + } catch (final KeyStoreException | NoSuchAlgorithmException | KeyManagementException e) { + LOG.error("Failed to create Cloudian API client due to: ", e); + } + throw new CloudRuntimeException("Failed to create and return Cloudian API client instance"); + } + + private boolean addGroup(final Domain domain) { + if (domain == null || !isEnabled()) { + return false; + } + final CloudianClient client = getClient(); + final CloudianGroup group = new CloudianGroup(); + group.setGroupId(domain.getUuid()); + group.setGroupName(domain.getPath()); + group.setActive(true); + return client.addGroup(group); + } + + private boolean removeGroup(final Domain domain) { + if (domain == null || !isEnabled()) { + return false; + } + final CloudianClient client = getClient(); + for (final CloudianUser user: client.listUsers(domain.getUuid())) { + if (client.removeUser(user.getUserId(), domain.getUuid())) { + LOG.error(String.format("Failed to remove Cloudian user id=%s, while removing Cloudian group id=%s", user.getUserId(), domain.getUuid())); + } + } + for (int retry = 0; retry < 3; retry++) { + if (client.removeGroup(domain.getUuid())) { + return true; + } else { + LOG.warn("Failed to remove Cloudian group id=" + domain.getUuid() + ", retrying count=" + retry+1); + } + } + LOG.warn("Failed to remove Cloudian group id=" + domain.getUuid() + ", please remove manually"); + return false; + } + + private boolean addUserAccount(final Account account, final Domain domain) { + if (account == null || domain == null || !isEnabled()) { + return false; + } + final User accountUser = userDao.listByAccount(account.getId()).get(0); + final CloudianClient client = getClient(); + final String fullName = String.format("%s %s (%s)", accountUser.getFirstname(), accountUser.getLastname(), account.getAccountName()); + final CloudianUser user = new CloudianUser(); + user.setUserId(account.getUuid()); + user.setGroupId(domain.getUuid()); + user.setFullName(fullName); + user.setEmailAddr(accountUser.getEmail()); + user.setUserType(CloudianUser.USER); + user.setActive(true); + return client.addUser(user); + } + + private boolean updateUserAccount(final Account account, final Domain domain, final CloudianUser existingUser) { + if (account == null || domain == null || !isEnabled()) { + return false; + } + final CloudianClient client = getClient(); + if (existingUser != null) { + final User accountUser = userDao.listByAccount(account.getId()).get(0); + final String fullName = String.format("%s %s (%s)", accountUser.getFirstname(), accountUser.getLastname(), account.getAccountName()); + if (!existingUser.getActive() || !existingUser.getFullName().equals(fullName) || !existingUser.getEmailAddr().equals(accountUser.getEmail())) { + existingUser.setActive(true); + existingUser.setFullName(fullName); + existingUser.setEmailAddr(accountUser.getEmail()); + return client.updateUser(existingUser); + } + return true; + } + return false; + } + + private boolean removeUserAccount(final Account account) { + if (account == null || !isEnabled()) { + return false; + } + final CloudianClient client = getClient(); + final Domain domain = domainDao.findById(account.getDomainId()); + for (int retry = 0; retry < 3; retry++) { + if (client.removeUser(account.getUuid(), domain.getUuid())) { + return true; + } else { + LOG.warn("Failed to remove Cloudian user id=" + account.getUuid() + " in group id=" + domain.getUuid() + ", retrying count=" + retry+1); + } + } + LOG.warn("Failed to remove Cloudian user id=" + account.getUuid() + " in group id=" + domain.getUuid() + ", please remove manually"); + return false; + } + + ////////////////////////////////////////////////// + //////////////// Plugin APIs ///////////////////// + ////////////////////////////////////////////////// + + @Override + public String getCmcUrl() { + return String.format("%s://%s:%s/Cloudian/", CloudianCmcProtocol.value(), + CloudianCmcHost.value(), CloudianCmcPort.value()); + } + + @Override + public boolean isEnabled() { + return CloudianConnectorEnabled.value(); + } + + @Override + public String generateSsoUrl() { + final Account caller = CallContext.current().getCallingAccount(); + final Domain domain = domainDao.findById(caller.getDomainId()); + + String user = caller.getUuid(); + String group = domain.getUuid(); + + if (caller.getAccountName().equals("admin") && caller.getRoleId() == RoleType.Admin.getId()) { + user = CloudianCmcAdminUser.value(); + group = "0"; + } + + LOG.debug(String.format("Attempting Cloudian SSO with user id=%s, group id=%s", user, group)); + + final CloudianUser ssoUser = getClient().listUser(user, group); + if (ssoUser == null || !ssoUser.getActive()) { + LOG.debug(String.format("Failed to find existing Cloudian user id=%s in group id=%s", user, group)); + final CloudianGroup ssoGroup = getClient().listGroup(group); + if (ssoGroup == null) { + LOG.debug(String.format("Failed to find existing Cloudian group id=%s, trying to add it", group)); + if (!addGroup(domain)) { + LOG.error("Failed to add missing Cloudian group id=" + group); + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Aborting Cloudian SSO, failed to add group to Cloudian."); + } + } + if (!addUserAccount(caller, domain)) { + LOG.error("Failed to add missing Cloudian group id=" + group); + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Aborting Cloudian SSO, failed to add user to Cloudian."); + } + final CloudianUser addedSsoUser = getClient().listUser(user, group); + if (addedSsoUser == null || !addedSsoUser.getActive()) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Aborting Cloudian SSO, failed to find mapped Cloudian user, please fix integration issues."); + } + } else if (!group.equals("0")) { + updateUserAccount(caller, domain, ssoUser); + } + + LOG.debug(String.format("Validated Cloudian SSO for Cloudian user id=%s, group id=%s", user, group)); + return CloudianUtils.generateSSOUrl(getCmcUrl(), user, group, CloudianSsoKey.value()); + } + + /////////////////////////////////////////////////////////// + //////////////// Plugin Configuration ///////////////////// + /////////////////////////////////////////////////////////// + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); + + if (!isEnabled()) { + LOG.debug("Cloudian connector is disabled, skipping configuration"); + return true; + } + + LOG.debug(String.format("Cloudian connector is enabled, completed configuration, integration is ready. " + + "Cloudian admin host:%s, port:%s, user:%s", + CloudianAdminHost.value(), CloudianAdminPort.value(), CloudianAdminUser.value())); + + messageBus.subscribe(AccountManager.MESSAGE_ADD_ACCOUNT_EVENT, new MessageSubscriber() { + @Override + public void onPublishMessage(String senderAddress, String subject, Object args) { + try { + final Map accountGroupMap = (Map) args; + final Long accountId = accountGroupMap.keySet().iterator().next(); + final Account account = accountDao.findById(accountId); + final Domain domain = domainDao.findById(account.getDomainId()); + + if (!addUserAccount(account, domain)) { + LOG.warn(String.format("Failed to add account in Cloudian while adding CloudStack account=%s in domain=%s", account.getAccountName(), domain.getPath())); + } + } catch (final Exception e) { + LOG.error("Caught exception while adding account in Cloudian: ", e); + } + } + }); + + messageBus.subscribe(AccountManager.MESSAGE_REMOVE_ACCOUNT_EVENT, new MessageSubscriber() { + @Override + public void onPublishMessage(String senderAddress, String subject, Object args) { + try { + final Account account = accountDao.findByIdIncludingRemoved((Long) args); + if(!removeUserAccount(account)) { + LOG.warn(String.format("Failed to remove account to Cloudian while removing CloudStack account=%s, id=%s", account.getAccountName(), account.getId())); + } + } catch (final Exception e) { + LOG.error("Caught exception while removing account in Cloudian: ", e); + } + } + }); + + messageBus.subscribe(DomainManager.MESSAGE_ADD_DOMAIN_EVENT, new MessageSubscriber() { + @Override + public void onPublishMessage(String senderAddress, String subject, Object args) { + try { + final Domain domain = domainDao.findById((Long) args); + if (!addGroup(domain)) { + LOG.warn(String.format("Failed to add group in Cloudian while adding CloudStack domain=%s id=%s", domain.getPath(), domain.getId())); + } + } catch (final Exception e) { + LOG.error("Caught exception adding domain/group in Cloudian: ", e); + } + } + }); + + messageBus.subscribe(DomainManager.MESSAGE_REMOVE_DOMAIN_EVENT, new MessageSubscriber() { + @Override + public void onPublishMessage(String senderAddress, String subject, Object args) { + try { + final DomainVO domain = (DomainVO) args; + if (!removeGroup(domain)) { + LOG.warn(String.format("Failed to remove group in Cloudian while removing CloudStack domain=%s id=%s", domain.getPath(), domain.getId())); + } + } catch (final Exception e) { + LOG.error("Caught exception while removing domain/group in Cloudian: ", e); + } + } + }); + + return true; + } + + @Override + public List> getCommands() { + final List> cmdList = new ArrayList>(); + cmdList.add(CloudianIsEnabledCmd.class); + if (!isEnabled()) { + return cmdList; + } + cmdList.add(CloudianSsoLoginCmd.class); + return cmdList; + } + + @Override + public String getConfigComponentName() { + return CloudianConnector.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[] { + CloudianConnectorEnabled, + CloudianAdminHost, + CloudianAdminPort, + CloudianAdminUser, + CloudianAdminPassword, + CloudianAdminProtocol, + CloudianAdminApiRequestTimeout, + CloudianValidateSSLSecurity, + CloudianCmcAdminUser, + CloudianCmcHost, + CloudianCmcPort, + CloudianCmcProtocol, + CloudianSsoKey + }; + } +} diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/api/CloudianIsEnabledCmd.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/api/CloudianIsEnabledCmd.java new file mode 100644 index 000000000000..fdca87185d04 --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/api/CloudianIsEnabledCmd.java @@ -0,0 +1,65 @@ +// 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.cloudstack.cloudian.api; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.cloudian.CloudianConnector; +import org.apache.cloudstack.cloudian.response.CloudianEnabledResponse; + +import com.cloud.user.Account; + +@APICommand(name = CloudianIsEnabledCmd.APINAME, description = "Checks if the Cloudian Connector is enabled", + responseObject = CloudianEnabledResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.11.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class CloudianIsEnabledCmd extends BaseCmd { + public static final String APINAME = "cloudianIsEnabled"; + + @Inject + private CloudianConnector connector; + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public String getCommandName() { + return APINAME.toLowerCase() + BaseCmd.RESPONSE_SUFFIX; + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + + @Override + public void execute() { + final CloudianEnabledResponse response = new CloudianEnabledResponse(); + response.setEnabled(connector.isEnabled()); + response.setCmcUrl(connector.getCmcUrl()); + response.setObjectName(APINAME.toLowerCase()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/api/CloudianSsoLoginCmd.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/api/CloudianSsoLoginCmd.java new file mode 100644 index 000000000000..7bdd7fda5e1b --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/api/CloudianSsoLoginCmd.java @@ -0,0 +1,70 @@ +// 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.cloudstack.cloudian.api; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.cloudian.CloudianConnector; +import org.apache.cloudstack.cloudian.response.CloudianSsoLoginResponse; + +import com.cloud.user.Account; +import com.google.common.base.Strings; + +@APICommand(name = CloudianSsoLoginCmd.APINAME, description = "Generates single-sign-on login url for logged-in CloudStack user to access the Cloudian Management Console", + responseObject = CloudianSsoLoginResponse.class, + since = "4.11.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class CloudianSsoLoginCmd extends BaseCmd { + public static final String APINAME = "cloudianSsoLogin"; + + @Inject + private CloudianConnector connector; + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public String getCommandName() { + return APINAME.toLowerCase() + BaseCmd.RESPONSE_SUFFIX; + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + + @Override + public void execute() { + final String ssoUrl = connector.generateSsoUrl(); + if (Strings.isNullOrEmpty(ssoUrl)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to generate Cloudian single-sign on URL for the user"); + } + final CloudianSsoLoginResponse response = new CloudianSsoLoginResponse(); + response.setSsoRedirectUrl(ssoUrl); + response.setResponseName(getCommandName()); + response.setObjectName(APINAME.toLowerCase()); + setResponseObject(response); + } +} diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianClient.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianClient.java new file mode 100644 index 000000000000..11f2055fef8d --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianClient.java @@ -0,0 +1,347 @@ +// 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.cloudstack.cloudian.client; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; + +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.utils.security.SSLUtils; +import org.apache.http.HttpHost; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.Credentials; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.AuthCache; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.HttpClient; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.conn.ConnectTimeoutException; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.auth.BasicScheme; +import org.apache.http.impl.client.BasicAuthCache; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.log4j.Logger; + +import com.cloud.utils.nio.TrustAllManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Strings; + +public class CloudianClient { + private static final Logger LOG = Logger.getLogger(CloudianClient.class); + + private final HttpClient httpClient; + private final HttpClientContext httpContext; + private final String adminApiUrl; + + public CloudianClient(final String host, final Integer port, final String scheme, final String username, final String password, final boolean validateSSlCertificate, final int timeout) throws KeyStoreException, NoSuchAlgorithmException, KeyManagementException { + final CredentialsProvider provider = new BasicCredentialsProvider(); + provider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, password)); + final HttpHost adminHost = new HttpHost(host, port, scheme); + final AuthCache authCache = new BasicAuthCache(); + authCache.put(adminHost, new BasicScheme()); + + this.adminApiUrl = adminHost.toURI(); + this.httpContext = HttpClientContext.create(); + this.httpContext.setCredentialsProvider(provider); + this.httpContext.setAuthCache(authCache); + + final RequestConfig config = RequestConfig.custom() + .setConnectTimeout(timeout * 1000) + .setConnectionRequestTimeout(timeout * 1000) + .setSocketTimeout(timeout * 1000) + .build(); + + if (!validateSSlCertificate) { + final SSLContext sslcontext = SSLUtils.getSSLContext(); + sslcontext.init(null, new X509TrustManager[]{new TrustAllManager()}, new SecureRandom()); + final SSLConnectionSocketFactory factory = new SSLConnectionSocketFactory(sslcontext, NoopHostnameVerifier.INSTANCE); + this.httpClient = HttpClientBuilder.create() + .setDefaultCredentialsProvider(provider) + .setDefaultRequestConfig(config) + .setSSLSocketFactory(factory) + .build(); + } else { + this.httpClient = HttpClientBuilder.create() + .setDefaultCredentialsProvider(provider) + .setDefaultRequestConfig(config) + .build(); + } + } + + private void checkAuthFailure(final HttpResponse response) { + if (response != null && response.getStatusLine().getStatusCode() == HttpStatus.SC_UNAUTHORIZED) { + final Credentials credentials = httpContext.getCredentialsProvider().getCredentials(AuthScope.ANY); + LOG.error("Cloudian admin API authentication failed, please check Cloudian configuration. Admin auth principal=" + credentials.getUserPrincipal() + ", password=" + credentials.getPassword() + ", API url=" + adminApiUrl); + throw new ServerApiException(ApiErrorCode.UNAUTHORIZED, "Cloudian backend API call unauthorized, please ask your administrator to fix integration issues."); + } + } + + private void checkResponseOK(final HttpResponse response) { + if (response.getStatusLine().getStatusCode() == HttpStatus.SC_NO_CONTENT) { + LOG.debug("Requested Cloudian resource does not exist"); + return; + } + if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK && response.getStatusLine().getStatusCode() != HttpStatus.SC_NO_CONTENT) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to find the requested resource and get valid response from Cloudian backend API call, please ask your administrator to diagnose and fix issues."); + } + } + + private boolean checkEmptyResponse(final HttpResponse response) throws IOException { + return response != null && (response.getStatusLine().getStatusCode() == HttpStatus.SC_NO_CONTENT || + response.getEntity() == null || + response.getEntity().getContent() == null); + } + + private void checkResponseTimeOut(final Exception e) { + if (e instanceof ConnectTimeoutException || e instanceof SocketTimeoutException) { + throw new ServerApiException(ApiErrorCode.RESOURCE_UNAVAILABLE_ERROR, "Operation timed out, please try again."); + } + } + + private HttpResponse delete(final String path) throws IOException { + final HttpResponse response = httpClient.execute(new HttpDelete(adminApiUrl + path), httpContext); + checkAuthFailure(response); + return response; + } + + private HttpResponse get(final String path) throws IOException { + final HttpResponse response = httpClient.execute(new HttpGet(adminApiUrl + path), httpContext); + checkAuthFailure(response); + return response; + } + + private HttpResponse post(final String path, final Object item) throws IOException { + final ObjectMapper mapper = new ObjectMapper(); + final String json = mapper.writeValueAsString(item); + final StringEntity entity = new StringEntity(json); + final HttpPost request = new HttpPost(adminApiUrl + path); + request.setHeader("Content-type", "application/json"); + request.setEntity(entity); + final HttpResponse response = httpClient.execute(request, httpContext); + checkAuthFailure(response); + return response; + } + + private HttpResponse put(final String path, final Object item) throws IOException { + final ObjectMapper mapper = new ObjectMapper(); + final String json = mapper.writeValueAsString(item); + final StringEntity entity = new StringEntity(json); + final HttpPut request = new HttpPut(adminApiUrl + path); + request.setHeader("Content-type", "application/json"); + request.setEntity(entity); + final HttpResponse response = httpClient.execute(request, httpContext); + checkAuthFailure(response); + return response; + } + + //////////////////////////////////////////////////////// + //////////////// Public APIs: User ///////////////////// + //////////////////////////////////////////////////////// + + public boolean addUser(final CloudianUser user) { + if (user == null) { + return false; + } + LOG.debug("Adding Cloudian user: " + user); + try { + final HttpResponse response = put("/user", user); + return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; + } catch (final IOException e) { + LOG.error("Failed to add Cloudian user due to:", e); + checkResponseTimeOut(e); + } + return false; + } + + public CloudianUser listUser(final String userId, final String groupId) { + if (Strings.isNullOrEmpty(userId) || Strings.isNullOrEmpty(groupId)) { + return null; + } + LOG.debug("Trying to find Cloudian user with id=" + userId + " and group id=" + groupId); + try { + final HttpResponse response = get(String.format("/user?userId=%s&groupId=%s", userId, groupId)); + checkResponseOK(response); + if (checkEmptyResponse(response)) { + return null; + } + final ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(response.getEntity().getContent(), CloudianUser.class); + } catch (final IOException e) { + LOG.error("Failed to list Cloudian user due to:", e); + checkResponseTimeOut(e); + } + return null; + } + + public List listUsers(final String groupId) { + if (Strings.isNullOrEmpty(groupId)) { + return new ArrayList<>(); + } + LOG.debug("Trying to list Cloudian users in group id=" + groupId); + try { + final HttpResponse response = get(String.format("/user/list?groupId=%s&userType=all&userStatus=active", groupId)); + checkResponseOK(response); + if (checkEmptyResponse(response)) { + return new ArrayList<>(); + } + final ObjectMapper mapper = new ObjectMapper(); + return Arrays.asList(mapper.readValue(response.getEntity().getContent(), CloudianUser[].class)); + } catch (final IOException e) { + LOG.error("Failed to list Cloudian users due to:", e); + checkResponseTimeOut(e); + } + return new ArrayList<>(); + } + + public boolean updateUser(final CloudianUser user) { + if (user == null) { + return false; + } + LOG.debug("Updating Cloudian user: " + user); + try { + final HttpResponse response = post("/user", user); + return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; + } catch (final IOException e) { + LOG.error("Failed to update Cloudian user due to:", e); + checkResponseTimeOut(e); + } + return false; + } + + public boolean removeUser(final String userId, final String groupId) { + if (Strings.isNullOrEmpty(userId) || Strings.isNullOrEmpty(groupId)) { + return false; + } + LOG.debug("Removing Cloudian user with user id=" + userId + " in group id=" + groupId); + try { + final HttpResponse response = delete(String.format("/user?userId=%s&groupId=%s", userId, groupId)); + return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; + } catch (final IOException e) { + LOG.error("Failed to remove Cloudian user due to:", e); + checkResponseTimeOut(e); + } + return false; + } + + ///////////////////////////////////////////////////////// + //////////////// Public APIs: Group ///////////////////// + ///////////////////////////////////////////////////////// + + public boolean addGroup(final CloudianGroup group) { + if (group == null) { + return false; + } + LOG.debug("Adding Cloudian group: " + group); + try { + final HttpResponse response = put("/group", group); + return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; + } catch (final IOException e) { + LOG.error("Failed to add Cloudian group due to:", e); + checkResponseTimeOut(e); + } + return false; + } + + public CloudianGroup listGroup(final String groupId) { + if (Strings.isNullOrEmpty(groupId)) { + return null; + } + LOG.debug("Trying to find Cloudian group with id=" + groupId); + try { + final HttpResponse response = get(String.format("/group?groupId=%s", groupId)); + checkResponseOK(response); + if (checkEmptyResponse(response)) { + return null; + } + final ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(response.getEntity().getContent(), CloudianGroup.class); + } catch (final IOException e) { + LOG.error("Failed to list Cloudian group due to:", e); + checkResponseTimeOut(e); + } + return null; + } + + public List listGroups() { + LOG.debug("Trying to list Cloudian groups"); + try { + final HttpResponse response = get("/group/list"); + checkResponseOK(response); + if (checkEmptyResponse(response)) { + return new ArrayList<>(); + } + final ObjectMapper mapper = new ObjectMapper(); + return Arrays.asList(mapper.readValue(response.getEntity().getContent(), CloudianGroup[].class)); + } catch (final IOException e) { + LOG.error("Failed to list Cloudian groups due to:", e); + checkResponseTimeOut(e); + } + return new ArrayList<>(); + } + + public boolean updateGroup(final CloudianGroup group) { + if (group == null) { + return false; + } + LOG.debug("Updating Cloudian group: " + group); + try { + final HttpResponse response = post("/group", group); + return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; + } catch (final IOException e) { + LOG.error("Failed to remove group due to:", e); + checkResponseTimeOut(e); + } + return false; + } + + public boolean removeGroup(final String groupId) { + if (Strings.isNullOrEmpty(groupId)) { + return false; + } + LOG.debug("Removing Cloudian group id=" + groupId); + try { + final HttpResponse response = delete(String.format("/group?groupId=%s", groupId)); + return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK; + } catch (final IOException e) { + LOG.error("Failed to remove group due to:", e); + checkResponseTimeOut(e); + } + return false; + } +} diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianGroup.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianGroup.java new file mode 100644 index 000000000000..0a3c4e475a19 --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianGroup.java @@ -0,0 +1,56 @@ +// 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.cloudstack.cloudian.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class CloudianGroup { + String groupId; + String groupName; + Boolean active; + + @Override + public String toString() { + return String.format("Cloudian Group [id=%s, name=%s, active=%s]", groupId, groupName, active); + } + + public String getGroupId() { + return groupId; + } + + public void setGroupId(String groupId) { + this.groupId = groupId; + } + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } +} diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianUser.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianUser.java new file mode 100644 index 000000000000..88d45f90976d --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianUser.java @@ -0,0 +1,85 @@ +// 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.cloudstack.cloudian.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class CloudianUser { + public static final String USER = "User"; + + String userId; + String groupId; + String userType; + String fullName; + String emailAddr; + Boolean active; + + @Override + public String toString() { + return String.format("Cloudian User [id=%s, group id=%s, type=%s, active=%s, name=%s]", userId, groupId, userType, active, fullName); + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getGroupId() { + return groupId; + } + + public void setGroupId(String groupId) { + this.groupId = groupId; + } + + public String getUserType() { + return userType; + } + + public void setUserType(String userType) { + this.userType = userType; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getEmailAddr() { + return emailAddr; + } + + public void setEmailAddr(String emailAddr) { + this.emailAddr = emailAddr; + } + + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } +} diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianUtils.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianUtils.java new file mode 100644 index 000000000000..faca579886e4 --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/client/CloudianUtils.java @@ -0,0 +1,92 @@ +// 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.cloudstack.cloudian.client; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.commons.codec.binary.Base64; +import org.apache.log4j.Logger; + +import com.cloud.utils.HttpUtils; +import com.google.common.base.Strings; + +public class CloudianUtils { + + private static final Logger LOG = Logger.getLogger(CloudianUtils.class); + private static final String HMAC_SHA1_ALGORITHM = "HmacSHA1"; + + /** + * Generates RFC-2104 compliant HMAC signature + * @param data + * @param key + * @return returns the generated signature or null on error + */ + public static String generateHMACSignature(final String data, final String key) { + if (Strings. isNullOrEmpty(data) || Strings.isNullOrEmpty(key)) { + return null; + } + try { + final SecretKeySpec signingKey = new SecretKeySpec(key.getBytes(), HMAC_SHA1_ALGORITHM); + final Mac mac = Mac.getInstance(HMAC_SHA1_ALGORITHM); + mac.init(signingKey); + byte[] rawHmac = mac.doFinal(data.getBytes()); + return Base64.encodeBase64String(rawHmac); + } catch (final Exception e) { + LOG.error("Failed to generate HMAC signature from provided data and key, due to: ", e); + } + return null; + } + + /** + * Generates URL parameters for single-sign on URL + * @param user + * @param group + * @param ssoKey + * @return returns SSO URL parameters or null on error + */ + public static String generateSSOUrl(final String cmcUrlPath, final String user, final String group, final String ssoKey) { + final StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("user=").append(user); + stringBuilder.append("&group=").append(group); + stringBuilder.append("×tamp=").append(System.currentTimeMillis()); + + final String signature = generateHMACSignature(stringBuilder.toString(), ssoKey); + if (Strings.isNullOrEmpty(signature)) { + return null; + } + + try { + stringBuilder.append("&signature=").append(URLEncoder.encode(signature, HttpUtils.UTF_8)); + } catch (final UnsupportedEncodingException e) { + return null; + } + + stringBuilder.append("&redirect="); + if (group.equals("0")) { + stringBuilder.append("admin.htm"); + } else { + stringBuilder.append("explorer.htm"); + } + + return cmcUrlPath + "ssosecurelogin.htm?" + stringBuilder.toString(); + } +} diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianEnabledResponse.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianEnabledResponse.java new file mode 100644 index 000000000000..49d8cda2eafb --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianEnabledResponse.java @@ -0,0 +1,42 @@ +// 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.cloudstack.cloudian.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class CloudianEnabledResponse extends BaseResponse { + @SerializedName(ApiConstants.ENABLED) + @Param(description = "the Cloudian connector enabled state") + private Boolean enabled; + + @SerializedName(ApiConstants.URL) + @Param(description = "the Cloudian Management Console base URL") + private String cmcUrl; + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public void setCmcUrl(String cmcUrl) { + this.cmcUrl = cmcUrl; + } +} diff --git a/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianSsoLoginResponse.java b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianSsoLoginResponse.java new file mode 100644 index 000000000000..1731456e1970 --- /dev/null +++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianSsoLoginResponse.java @@ -0,0 +1,34 @@ +// 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.cloudstack.cloudian.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class CloudianSsoLoginResponse extends BaseResponse { + @SerializedName(ApiConstants.URL) + @Param(description = "the sso redirect url") + private String ssoRedirectUrl; + + public void setSsoRedirectUrl(final String ssoRedirectUrl) { + this.ssoRedirectUrl = ssoRedirectUrl; + } +} diff --git a/plugins/integrations/cloudian/test/org/apache/cloudstack/cloudian/CloudianClientTest.java b/plugins/integrations/cloudian/test/org/apache/cloudstack/cloudian/CloudianClientTest.java new file mode 100644 index 000000000000..23ba1e1294bd --- /dev/null +++ b/plugins/integrations/cloudian/test/org/apache/cloudstack/cloudian/CloudianClientTest.java @@ -0,0 +1,416 @@ +// 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.cloudstack.cloudian; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; + +import java.util.List; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.cloudian.client.CloudianClient; +import org.apache.cloudstack.cloudian.client.CloudianGroup; +import org.apache.cloudstack.cloudian.client.CloudianUser; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import com.cloud.utils.exception.CloudRuntimeException; +import com.github.tomakehurst.wiremock.client.BasicCredentials; +import com.github.tomakehurst.wiremock.junit.WireMockRule; + +public class CloudianClientTest { + private final int port = 14333; + private final int timeout = 2; + private final String adminUsername = "admin"; + private final String adminPassword = "public"; + private CloudianClient client; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(port); + + @Before + public void setUp() throws Exception { + client = new CloudianClient("localhost", port, "http", adminUsername, adminPassword, false, timeout); + } + + private CloudianUser getTestUser() { + final CloudianUser user = new CloudianUser(); + user.setActive(true); + user.setUserId("someUserId"); + user.setGroupId("someGroupId"); + user.setUserType(CloudianUser.USER); + user.setFullName("John Doe"); + return user; + } + + private CloudianGroup getTestGroup() { + final CloudianGroup group = new CloudianGroup(); + group.setActive(true); + group.setGroupId("someGroupId"); + group.setGroupName("someGroupName"); + return group; + } + + //////////////////////////////////////////////////////// + //////////////// General API tests ///////////////////// + //////////////////////////////////////////////////////// + + @Test(expected = CloudRuntimeException.class) + public void testRequestTimeout() { + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withStatus(200) + .withFixedDelay(2 * timeout * 1000) + .withBody(""))); + client.listGroups(); + } + + @Test + public void testBasicAuth() { + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withStatus(200) + .withBody("[]"))); + client.listGroups(); + verify(getRequestedFor(urlEqualTo("/group/list")) + .withBasicAuth(new BasicCredentials(adminUsername, adminPassword))); + } + + @Test(expected = ServerApiException.class) + public void testBasicAuthFailure() { + wireMockRule.stubFor(get(urlPathMatching("/user")) + .willReturn(aResponse() + .withStatus(401) + .withBody(""))); + client.listUser("someUserId", "somegGroupId"); + } + + ///////////////////////////////////////////////////// + //////////////// User API tests ///////////////////// + ///////////////////////////////////////////////////// + + @Test + public void addUserAccount() { + wireMockRule.stubFor(put(urlEqualTo("/user")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + + final CloudianUser user = getTestUser(); + boolean result = client.addUser(user); + Assert.assertTrue(result); + verify(putRequestedFor(urlEqualTo("/user")) + .withRequestBody(containing("userId\":\"" + user.getUserId())) + .withHeader("Content-Type", equalTo("application/json"))); + } + + @Test + public void addUserAccountFail() { + wireMockRule.stubFor(put(urlEqualTo("/user")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + + final CloudianUser user = getTestUser(); + boolean result = client.addUser(user); + Assert.assertFalse(result); + } + + @Test + public void listUserAccount() { + final String userId = "someUser"; + final String groupId = "someGroup"; + wireMockRule.stubFor(get(urlPathMatching("/user?.*")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"userId\":\"someUser\",\"userType\":\"User\",\"fullName\":\"John Doe (jdoe)\",\"emailAddr\":\"j@doe.com\",\"address1\":null,\"address2\":null,\"city\":null,\"state\":null,\"zip\":null,\"country\":null,\"phone\":null,\"groupId\":\"someGroup\",\"website\":null,\"active\":\"true\",\"canonicalUserId\":\"b3940886468689d375ebf8747b151c37\",\"ldapEnabled\":false}"))); + + final CloudianUser user = client.listUser(userId, groupId); + Assert.assertEquals(user.getActive(), true); + Assert.assertEquals(user.getUserId(), userId); + Assert.assertEquals(user.getGroupId(), groupId); + Assert.assertEquals(user.getUserType(), "User"); + } + + @Test + public void listUserAccountFail() { + wireMockRule.stubFor(get(urlPathMatching("/user?.*")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(""))); + + final CloudianUser user = client.listUser("abc", "xyz"); + Assert.assertNull(user); + } + + @Test + public void listUserAccounts() { + final String groupId = "someGroup"; + wireMockRule.stubFor(get(urlPathMatching("/user/list?.*")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("[{\"userId\":\"someUser\",\"userType\":\"User\",\"fullName\":\"John Doe (jdoe)\",\"emailAddr\":\"j@doe.com\",\"address1\":null,\"address2\":null,\"city\":null,\"state\":null,\"zip\":null,\"country\":null,\"phone\":null,\"groupId\":\"someGroup\",\"website\":null,\"active\":\"true\",\"canonicalUserId\":\"b3940886468689d375ebf8747b151c37\",\"ldapEnabled\":false}]"))); + + final List users = client.listUsers(groupId); + Assert.assertEquals(users.size(), 1); + Assert.assertEquals(users.get(0).getActive(), true); + Assert.assertEquals(users.get(0).getGroupId(), groupId); + } + + @Test + public void testEmptyListUsersResponse() { + wireMockRule.stubFor(get(urlPathMatching("/user/list")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withStatus(204) + .withBody(""))); + Assert.assertTrue(client.listUsers("someGroup").size() == 0); + + wireMockRule.stubFor(get(urlPathMatching("/user")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withStatus(204) + .withBody(""))); + Assert.assertNull(client.listUser("someUserId", "someGroupId")); + } + + @Test + public void listUserAccountsFail() { + wireMockRule.stubFor(get(urlPathMatching("/user/list?.*")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(""))); + + final List users = client.listUsers("xyz"); + Assert.assertEquals(users.size(), 0); + } + + @Test + public void updateUserAccount() { + wireMockRule.stubFor(post(urlEqualTo("/user")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + + final CloudianUser user = getTestUser(); + boolean result = client.updateUser(user); + Assert.assertTrue(result); + verify(postRequestedFor(urlEqualTo("/user")) + .withRequestBody(containing("userId\":\"" + user.getUserId())) + .withHeader("Content-Type", equalTo("application/json"))); + } + + @Test + public void updateUserAccountFail() { + wireMockRule.stubFor(post(urlEqualTo("/user")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + + boolean result = client.updateUser(getTestUser()); + Assert.assertFalse(result); + } + + @Test + public void removeUserAccount() { + wireMockRule.stubFor(delete(urlPathMatching("/user.*")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + final CloudianUser user = getTestUser(); + boolean result = client.removeUser(user.getUserId(), user.getGroupId()); + Assert.assertTrue(result); + verify(deleteRequestedFor(urlPathMatching("/user.*")) + .withQueryParam("userId", equalTo(user.getUserId()))); + } + + @Test + public void removeUserAccountFail() { + wireMockRule.stubFor(delete(urlPathMatching("/user.*")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + final CloudianUser user = getTestUser(); + boolean result = client.removeUser(user.getUserId(), user.getGroupId()); + Assert.assertFalse(result); + } + + ////////////////////////////////////////////////////// + //////////////// Group API tests ///////////////////// + ////////////////////////////////////////////////////// + + @Test + public void addGroup() { + wireMockRule.stubFor(put(urlEqualTo("/group")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + + final CloudianGroup group = getTestGroup(); + boolean result = client.addGroup(group); + Assert.assertTrue(result); + verify(putRequestedFor(urlEqualTo("/group")) + .withRequestBody(containing("groupId\":\"someGroupId")) + .withHeader("Content-Type", equalTo("application/json"))); + } + + @Test + public void addGroupFail() throws Exception { + wireMockRule.stubFor(put(urlEqualTo("/group")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + + final CloudianGroup group = getTestGroup(); + boolean result = client.addGroup(group); + Assert.assertFalse(result); + } + + @Test + public void listGroup() { + final String groupId = "someGroup"; + wireMockRule.stubFor(get(urlPathMatching("/group.*")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"groupId\":\"someGroup\",\"groupName\":\"/someDomain\",\"ldapGroup\":null,\"active\":\"true\",\"ldapEnabled\":false,\"ldapServerURL\":null,\"ldapUserDNTemplate\":null,\"ldapSearch\":null,\"ldapSearchUserBase\":null,\"ldapMatchAttribute\":null}"))); + + final CloudianGroup group = client.listGroup(groupId); + Assert.assertEquals(group.getActive(), true); + Assert.assertEquals(group.getGroupId(), groupId); + } + + @Test + public void listGroupFail() { + wireMockRule.stubFor(get(urlPathMatching("/group.*")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(""))); + + final CloudianGroup group = client.listGroup("xyz"); + Assert.assertNull(group); + } + + @Test + public void listGroups() { + final String groupId = "someGroup"; + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("[{\"groupId\":\"someGroup\",\"groupName\":\"/someDomain\",\"ldapGroup\":null,\"active\":\"true\",\"ldapEnabled\":false,\"ldapServerURL\":null,\"ldapUserDNTemplate\":null,\"ldapSearch\":null,\"ldapSearchUserBase\":null,\"ldapMatchAttribute\":null}]"))); + + final List groups = client.listGroups(); + Assert.assertEquals(groups.size(), 1); + Assert.assertEquals(groups.get(0).getActive(), true); + Assert.assertEquals(groups.get(0).getGroupId(), groupId); + } + + @Test + public void listGroupsFail() { + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(""))); + + final List groups = client.listGroups(); + Assert.assertEquals(groups.size(), 0); + } + + @Test + public void testEmptyListGroupResponse() { + wireMockRule.stubFor(get(urlEqualTo("/group/list")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withStatus(204) + .withBody(""))); + + Assert.assertTrue(client.listGroups().size() == 0); + + + wireMockRule.stubFor(get(urlPathMatching("/group")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withStatus(204) + .withBody(""))); + Assert.assertNull(client.listGroup("someGroup")); + } + + @Test + public void updateGroup() { + wireMockRule.stubFor(post(urlEqualTo("/group")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + + final CloudianGroup group = getTestGroup(); + boolean result = client.updateGroup(group); + Assert.assertTrue(result); + verify(postRequestedFor(urlEqualTo("/group")) + .withRequestBody(containing("groupId\":\"" + group.getGroupId())) + .withHeader("Content-Type", equalTo("application/json"))); + } + + @Test + public void updateGroupFail() { + wireMockRule.stubFor(post(urlEqualTo("/group")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + + boolean result = client.updateGroup(getTestGroup()); + Assert.assertFalse(result); + } + + @Test + public void removeGroup() { + wireMockRule.stubFor(delete(urlPathMatching("/group.*")) + .willReturn(aResponse() + .withStatus(200) + .withBody(""))); + final CloudianGroup group = getTestGroup(); + boolean result = client.removeGroup(group.getGroupId()); + Assert.assertTrue(result); + verify(deleteRequestedFor(urlPathMatching("/group.*")) + .withQueryParam("groupId", equalTo(group.getGroupId()))); + } + + @Test + public void removeGroupFail() { + wireMockRule.stubFor(delete(urlPathMatching("/group.*")) + .willReturn(aResponse() + .withStatus(400) + .withBody(""))); + final CloudianGroup group = getTestGroup(); + boolean result = client.removeGroup(group.getGroupId()); + Assert.assertFalse(result); + } +} \ No newline at end of file diff --git a/plugins/pom.xml b/plugins/pom.xml index 1ee7af58c60f..f57fbdc126c0 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -107,6 +107,7 @@ network-elements/vxlan network-elements/globodns database/quota + integrations/cloudian integrations/prometheus diff --git a/pom.xml b/pom.xml index 2ff63e6f455f..96fe92503e2e 100644 --- a/pom.xml +++ b/pom.xml @@ -124,6 +124,7 @@ 3.1.4 2.4.7 10.1 + 2.8.0 diff --git a/ui/plugins/cloudian/cloudian.css b/ui/plugins/cloudian/cloudian.css new file mode 100644 index 000000000000..447cf434b320 --- /dev/null +++ b/ui/plugins/cloudian/cloudian.css @@ -0,0 +1,18 @@ +/* +* 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. +*/ diff --git a/ui/plugins/cloudian/cloudian.js b/ui/plugins/cloudian/cloudian.js new file mode 100644 index 000000000000..1b8a35ab1e97 --- /dev/null +++ b/ui/plugins/cloudian/cloudian.js @@ -0,0 +1,66 @@ +// 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. + +(function (cloudStack) { + cloudStack.plugins.cloudian = function(plugin) { + + plugin.ui.addSection({ + id: 'cloudian', + title: 'Cloudian Storage', + showOnNavigation: true, + preFilter: function(args) { + var pluginEnabled = false; + $.ajax({ + url: createURL('cloudianIsEnabled'), + async: false, + success: function(json) { + var response = json.cloudianisenabledresponse.cloudianisenabled; + pluginEnabled = response.enabled; + if (pluginEnabled) { + var cloudianLogoutUrl = response.url + "logout.htm?"; + onLogoutCallback = function() { + g_loginResponse = null; + var csUrl = window.location.href; + var redirect = "redirect=" + encodeURIComponent(csUrl); + window.location.replace(cloudianLogoutUrl + redirect); + return false; + }; + } + } + }); + return pluginEnabled; + }, + + show: function() { + var description = 'Cloudian Management Console should open in another window.'; + $.ajax({ + url: createURL('cloudianSsoLogin'), + async: false, + success: function(json) { + var response = json.cloudianssologinresponse.cloudianssologin; + var cmcWindow = window.open(response.url, "CMCWindow"); + cmcWindow.focus(); + }, + error: function(data) { + description = 'Single-Sign-On failed for Cloudian Management Console. Please ask your administrator to fix integration issues.'; + } + }); + return $('
').html(description); + } + }); + }; +}(cloudStack)); diff --git a/ui/plugins/cloudian/config.js b/ui/plugins/cloudian/config.js new file mode 100644 index 000000000000..b72cd5f2ff97 --- /dev/null +++ b/ui/plugins/cloudian/config.js @@ -0,0 +1,25 @@ +// 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. +(function (cloudStack) { + cloudStack.plugins.cloudian.config = { + title: 'Cloudian Storage', + desc: 'Cloudian Storage', + externalLink: 'https://cloudian.com/', + authorName: 'Cloudian Inc.', + authorEmail: 'info@cloudian.com ' + }; +}(cloudStack)); diff --git a/ui/plugins/cloudian/icon.png b/ui/plugins/cloudian/icon.png new file mode 100644 index 000000000000..d18ec2376788 Binary files /dev/null and b/ui/plugins/cloudian/icon.png differ diff --git a/ui/plugins/plugins.js b/ui/plugins/plugins.js index 21da7a07f4d0..6edfe88fe1d7 100644 --- a/ui/plugins/plugins.js +++ b/ui/plugins/plugins.js @@ -17,6 +17,7 @@ (function($, cloudStack) { cloudStack.plugins = [ //'testPlugin', + 'cloudian', 'quota' ]; }(jQuery, cloudStack));