You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cloudstack.apache.org by bh...@apache.org on 2017/10/25 05:19:50 UTC

[cloudstack] branch master updated: CLOUDSTACK-10103: Cloudian Connector for CloudStack (#2284)

This is an automated email from the ASF dual-hosted git repository.

bhaisaab pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/master by this push:
     new b6dc40f  CLOUDSTACK-10103: Cloudian Connector for CloudStack (#2284)
b6dc40f is described below

commit b6dc40faefdab52c2db35232fdab98e1c65c0b9e
Author: Rohit Yadav <bh...@apache.org>
AuthorDate: Wed Oct 25 10:49:45 2017 +0530

    CLOUDSTACK-10103: Cloudian Connector for CloudStack (#2284)
    
    Several organizations use Cloudian as S3 provider, this implements the
    Cloudian Management Console connector for CloudStack that can do the
    following:
    
    - Provide ease in connector configuration using CloudStack global
      settings
    - Perform SSO from CloudStack UI into Cloudian Management Console (CMC)
      when the connector is enabled
    - Automatic provisioning and de-provisioning of CloudStack accounts and
      domains as Cloudian users and groups respectively
    - During CloudStack UI logout, logout user from CMC
    - CloudStack account will be mapped to Cloudian Users, and CloudStack
      domain will be mapped to Cloudian Groups.
    - The CloudStack admin account is mapped to Cloudian admin (user name
      configurable).
    - The user/group provisioning will be from CloudStack to Cloudian only,
      i.e. user/group addition/removal/updation/deactivation in Cloudian
      portal (CMC) won't propagate the changes to CloudStack.
    
    FS: https://cwiki.apache.org/confluence/display/CLOUDSTACK/Cloudian+Connector+for+CloudStack
    
    New APIs:
    - `cloudianIsEnabled`: API to check whether Cloudian Connector is enabled.
    - `cloudianSsoLogin`: Performs SSO for the logged-in, requesting user
                          and returns the URL that can be used to perform
                          SSO and log into CMC.
    
    New Global Settings:
    - cloudian.connector.enabled  (false)
    If set to true, this enables the Cloudian Connector for CloudStack.
    Restarting management server(s) is required.
    - cloudian.admin.host (s3-admin.cloudian.com)
    The host where Cloudian Admin services are accessible.
    - cloudian.admin.port (19443)
    The admin service port.
    - cloudian.admin.protocol (https)
    The admin service API scheme/protocol.
    - cloudian.validate.ssl (true)
     When set to true, this validates the certificate of the https-enabled
    admin API service.
    - cloudian.admin.user (sysadmin)
    The admin user's name when making (admin) API calls.
    - cloudian.admin.password (public)
    The admin password used when making (admin) API calls.
    - cloudian.api.request.timeout (5)
    The API request timeout in seconds used by the internal HTTP/s client.
    - cloudian.cmc.admin.user (admin)
    The CMC admin user's name.
    - cloudian.cmc.host (cmc.cloudian.com)
    The CMC host.
    - cloudian.cmc.port (8443)
    The CMC service port.
    - cloudian.cmc.protocol (https)
     The CMC service scheme/protocol.
    - cloudian.sso.key (ss0sh5r3dk3y)
    The Single-Sign-On shared key.
    
    Signed-off-by: Rohit Yadav <ro...@shapeblue.com>
---
 client/pom.xml                                     |   5 +
 plugins/integrations/cloudian/pom.xml              |  60 +++
 .../META-INF/cloudstack/cloudian/module.properties |  18 +
 .../cloudian/spring-cloudian-context.xml           |  25 ++
 .../cloudstack/cloudian/CloudianConnector.java     |  82 ++++
 .../cloudstack/cloudian/CloudianConnectorImpl.java | 345 +++++++++++++++++
 .../cloudian/api/CloudianIsEnabledCmd.java         |  65 ++++
 .../cloudian/api/CloudianSsoLoginCmd.java          |  70 ++++
 .../cloudstack/cloudian/client/CloudianClient.java | 347 +++++++++++++++++
 .../cloudstack/cloudian/client/CloudianGroup.java  |  56 +++
 .../cloudstack/cloudian/client/CloudianUser.java   |  85 +++++
 .../cloudstack/cloudian/client/CloudianUtils.java  |  92 +++++
 .../cloudian/response/CloudianEnabledResponse.java |  32 +-
 .../response/CloudianSsoLoginResponse.java         |  24 +-
 .../cloudstack/cloudian/CloudianClientTest.java    | 416 +++++++++++++++++++++
 plugins/pom.xml                                    |   1 +
 pom.xml                                            |   1 +
 ui/plugins/cloudian/cloudian.css                   |  18 +
 ui/plugins/cloudian/cloudian.js                    |  66 ++++
 ui/plugins/{plugins.js => cloudian/config.js}      |  15 +-
 ui/plugins/cloudian/icon.png                       | Bin 0 -> 1099 bytes
 ui/plugins/plugins.js                              |   1 +
 22 files changed, 1806 insertions(+), 18 deletions(-)

diff --git a/client/pom.xml b/client/pom.xml
index ae0fcaa..2b52833 100644
--- a/client/pom.xml
+++ b/client/pom.xml
@@ -416,6 +416,11 @@
     </dependency>
     <dependency>
       <groupId>org.apache.cloudstack</groupId>
+      <artifactId>cloud-plugin-integrations-cloudian-connector</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.cloudstack</groupId>
       <artifactId>cloud-plugin-integrations-prometheus-exporter</artifactId>
       <version>${project.version}</version>
     </dependency>
diff --git a/plugins/integrations/cloudian/pom.xml b/plugins/integrations/cloudian/pom.xml
new file mode 100644
index 0000000..3e2b635
--- /dev/null
+++ b/plugins/integrations/cloudian/pom.xml
@@ -0,0 +1,60 @@
+<!--
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+  http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <artifactId>cloud-plugin-integrations-cloudian-connector</artifactId>
+  <name>Apache CloudStack Plugin - Cloudian Connector</name>
+  <parent>
+    <groupId>org.apache.cloudstack</groupId>
+    <artifactId>cloudstack-plugins</artifactId>
+    <version>4.11.0.0-SNAPSHOT</version>
+    <relativePath>../../pom.xml</relativePath>
+  </parent>
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.cloudstack</groupId>
+      <artifactId>cloud-api</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.cloudstack</groupId>
+      <artifactId>cloud-utils</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.httpcomponents</groupId>
+      <artifactId>httpclient</artifactId>
+      <version>${cs.httpclient.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>com.fasterxml.jackson.core</groupId>
+      <artifactId>jackson-databind</artifactId>
+      <version>${cs.jackson.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>com.github.tomakehurst</groupId>
+      <artifactId>wiremock</artifactId>
+      <version>${cs.wiremock.version}</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+</project>
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 0000000..762c636
--- /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 0000000..71ed52d
--- /dev/null
+++ b/plugins/integrations/cloudian/resources/META-INF/cloudstack/cloudian/spring-cloudian-context.xml
@@ -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.
+-->
+<beans xmlns="http://www.springframework.org/schema/beans"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans
+                      http://www.springframework.org/schema/beans/spring-beans.xsd">
+    <bean id="cloudianConnector" class="org.apache.cloudstack.cloudian.CloudianConnectorImpl" >
+    </bean>
+</beans>
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 0000000..c04d70c
--- /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<Boolean> CloudianConnectorEnabled = new ConfigKey<>("Advanced", Boolean.class, "cloudian.connector.enabled", "false",
+            "If set to true, this enables the Cloudian Connector for CloudStack.", true);
+
+    ConfigKey<String> CloudianAdminHost = new ConfigKey<>("Advanced", String.class, "cloudian.admin.host", "s3-admin.cloudian.com",
+            "The hostname of the Cloudian Admin server.", true);
+
+    ConfigKey<Integer> CloudianAdminPort = new ConfigKey<>("Advanced", Integer.class, "cloudian.admin.port", "19443",
+            "The port of the Cloudian Admin server.", true);
+
+    ConfigKey<String> CloudianAdminProtocol = new ConfigKey<>("Advanced", String.class, "cloudian.admin.protocol", "https",
+            "The protocol of the Cloudian Admin server.", true);
+
+    ConfigKey<Boolean> 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<String> CloudianAdminUser = new ConfigKey<>("Advanced", String.class, "cloudian.admin.user", "sysadmin",
+            "The system admin user for accessing the Cloudian Admin server.", true);
+
+    ConfigKey<String> CloudianAdminPassword = new ConfigKey<>("Advanced", String.class, "cloudian.admin.password", "public",
+            "The system admin password for the Cloudian Admin server.", true);
+
+    ConfigKey<Integer> CloudianAdminApiRequestTimeout = new ConfigKey<>("Advanced", Integer.class, "cloudian.api.request.timeout", "5",
+            "The admin API request timeout in seconds.", true);
+
+    ConfigKey<String> CloudianCmcAdminUser = new ConfigKey<>("Advanced", String.class, "cloudian.cmc.admin.user", "admin",
+            "The admin user name for accessing the Cloudian Management Console.", true);
+
+    ConfigKey<String> CloudianCmcHost = new ConfigKey<>("Advanced", String.class, "cloudian.cmc.host", "cmc.cloudian.com",
+            "The hostname of the Cloudian Management Console.", true);
+
+    ConfigKey<String> CloudianCmcPort = new ConfigKey<>("Advanced", String.class, "cloudian.cmc.port", "8443",
+            "The port of the Cloudian Management Console.", true);
+
+    ConfigKey<String> CloudianCmcProtocol = new ConfigKey<>("Advanced", String.class, "cloudian.cmc.protocol", "https",
+            "The protocol of the Cloudian Management Console.", true);
+
+    ConfigKey<String> 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 0000000..cfb23da
--- /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<String, Object> 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<Long, Long> accountGroupMap = (Map<Long, Long>) 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<Class<?>> getCommands() {
+        final List<Class<?>> cmdList = new ArrayList<Class<?>>();
+        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 0000000..fdca871
--- /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 0000000..7bdd7fd
--- /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 0000000..11f2055
--- /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<CloudianUser> 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<CloudianGroup> 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 0000000..0a3c4e4
--- /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 0000000..88d45f9
--- /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 0000000..faca579
--- /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("&timestamp=").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/ui/plugins/plugins.js b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianEnabledResponse.java
similarity index 52%
copy from ui/plugins/plugins.js
copy to plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianEnabledResponse.java
index 21da7a0..49d8cda 100644
--- a/ui/plugins/plugins.js
+++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianEnabledResponse.java
@@ -14,9 +14,29 @@
 // KIND, either express or implied.  See the License for the
 // specific language governing permissions and limitations
 // under the License.
-(function($, cloudStack) {
-  cloudStack.plugins = [
-    //'testPlugin',
-    'quota'
-  ];
-}(jQuery, cloudStack));
+
+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/ui/plugins/plugins.js b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianSsoLoginResponse.java
similarity index 59%
copy from ui/plugins/plugins.js
copy to plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianSsoLoginResponse.java
index 21da7a0..1731456 100644
--- a/ui/plugins/plugins.js
+++ b/plugins/integrations/cloudian/src/org/apache/cloudstack/cloudian/response/CloudianSsoLoginResponse.java
@@ -14,9 +14,21 @@
 // KIND, either express or implied.  See the License for the
 // specific language governing permissions and limitations
 // under the License.
-(function($, cloudStack) {
-  cloudStack.plugins = [
-    //'testPlugin',
-    'quota'
-  ];
-}(jQuery, cloudStack));
+
+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 0000000..23ba1e1
--- /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<CloudianUser> 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<CloudianUser> 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<CloudianGroup> 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<CloudianGroup> 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 1ee7af5..f57fbdc 100755
--- a/plugins/pom.xml
+++ b/plugins/pom.xml
@@ -107,6 +107,7 @@
     <module>network-elements/vxlan</module>
     <module>network-elements/globodns</module>
     <module>database/quota</module>
+    <module>integrations/cloudian</module>
     <module>integrations/prometheus</module>
   </modules>
 
diff --git a/pom.xml b/pom.xml
index 2ff63e6..96fe925 100644
--- a/pom.xml
+++ b/pom.xml
@@ -124,6 +124,7 @@
     <cs.cxf.version>3.1.4</cs.cxf.version>
     <cs.groovy.version>2.4.7</cs.groovy.version>
     <cs.nitro.version>10.1</cs.nitro.version>
+    <cs.wiremock.version>2.8.0</cs.wiremock.version>
   </properties>
 
   <distributionManagement>
diff --git a/ui/plugins/cloudian/cloudian.css b/ui/plugins/cloudian/cloudian.css
new file mode 100644
index 0000000..447cf43
--- /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 0000000..1b8a35a
--- /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 $('<div style="margin: 20px;">').html(description);
+      }
+    });
+  };
+}(cloudStack));
diff --git a/ui/plugins/plugins.js b/ui/plugins/cloudian/config.js
similarity index 75%
copy from ui/plugins/plugins.js
copy to ui/plugins/cloudian/config.js
index 21da7a0..b72cd5f 100644
--- a/ui/plugins/plugins.js
+++ b/ui/plugins/cloudian/config.js
@@ -14,9 +14,12 @@
 // KIND, either express or implied.  See the License for the
 // specific language governing permissions and limitations
 // under the License.
-(function($, cloudStack) {
-  cloudStack.plugins = [
-    //'testPlugin',
-    'quota'
-  ];
-}(jQuery, cloudStack));
+(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 0000000..d18ec23
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 21da7a0..6edfe88 100644
--- a/ui/plugins/plugins.js
+++ b/ui/plugins/plugins.js
@@ -17,6 +17,7 @@
 (function($, cloudStack) {
   cloudStack.plugins = [
     //'testPlugin',
+    'cloudian',
     'quota'
   ];
 }(jQuery, cloudStack));

-- 
To stop receiving notification emails like this one, please contact
['"commits@cloudstack.apache.org" <co...@cloudstack.apache.org>'].