diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/ListUsersCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/ListUsersCmd.java index 27a78c738c9..2f29b1ec1e4 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/ListUsersCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/ListUsersCmd.java @@ -16,9 +16,11 @@ // under the License. package org.apache.cloudstack.api.command.admin.user; +import com.cloud.exception.InvalidParameterValueException; import com.cloud.server.ResourceIcon; import com.cloud.server.ResourceTag; import com.cloud.user.Account; +import com.cloud.user.User; import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.ResourceIconResponse; @@ -30,6 +32,7 @@ import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.UserResponse; +import org.apache.commons.lang3.EnumUtils; import java.util.List; @@ -63,6 +66,10 @@ public class ListUsersCmd extends BaseListAccountResourcesCmd implements UserCmd description = "flag to display the resource icon for users") private Boolean showIcon; + @Parameter(name = ApiConstants.USER_SOURCE, type = CommandType.STRING, since = "4.21.0.0", + description = "List users by their authentication source. Valid values are: native, ldap, saml2 and saml2disabled.") + private String userSource; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -91,6 +98,23 @@ public class ListUsersCmd extends BaseListAccountResourcesCmd implements UserCmd return showIcon != null ? showIcon : false; } + public User.Source getUserSource() { + if (userSource == null) { + return null; + } + + User.Source source = EnumUtils.getEnumIgnoreCase(User.Source.class, userSource); + if (source == null || List.of(User.Source.OAUTH2, User.Source.UNKNOWN).contains(source)) { + throw new InvalidParameterValueException(String.format("Invalid user source: %s. Valid values are: native, ldap, saml2 and saml2disabled.", userSource)); + } + + if (source == User.Source.NATIVE) { + return User.Source.UNKNOWN; + } + + return source; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java index df97a915700..5e4e6e1f3c8 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java @@ -67,7 +67,7 @@ public class UserResponse extends BaseResponse implements SetResourceIconRespons @Param(description = "the account type of the user") private Integer accountType; - @SerializedName("usersource") + @SerializedName(ApiConstants.USER_SOURCE) @Param(description = "the source type of the user in lowercase, such as native, ldap, saml2") private String userSource; diff --git a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java index 976d3817a0a..69979bd89c1 100644 --- a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java +++ b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java @@ -695,7 +695,7 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q String keyword = null; Pair, Integer> result = getUserListInternal(caller, permittedAccounts, listAll, id, - username, type, accountName, state, keyword, null, domainId, recursive, null); + username, type, accountName, state, keyword, null, domainId, recursive, null, null); ListResponse response = new ListResponse(); List userResponses = ViewResponseHelper.createUserResponse(ResponseView.Restricted, CallContext.current().getCallingAccount().getDomainId(), result.first().toArray(new UserAccountJoinVO[result.first().size()])); @@ -723,6 +723,7 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q Object state = cmd.getState(); String keyword = cmd.getKeyword(); String apiKeyAccess = cmd.getApiKeyAccess(); + User.Source userSource = cmd.getUserSource(); Long domainId = cmd.getDomainId(); boolean recursive = cmd.isRecursive(); @@ -731,11 +732,11 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q Filter searchFilter = new Filter(UserAccountJoinVO.class, "id", true, startIndex, pageSizeVal); - return getUserListInternal(caller, permittedAccounts, listAll, id, username, type, accountName, state, keyword, apiKeyAccess, domainId, recursive, searchFilter); + return getUserListInternal(caller, permittedAccounts, listAll, id, username, type, accountName, state, keyword, apiKeyAccess, domainId, recursive, searchFilter, userSource); } private Pair, Integer> getUserListInternal(Account caller, List permittedAccounts, boolean listAll, Long id, Object username, Object type, - String accountName, Object state, String keyword, String apiKeyAccess, Long domainId, boolean recursive, Filter searchFilter) { + String accountName, Object state, String keyword, String apiKeyAccess, Long domainId, boolean recursive, Filter searchFilter, User.Source userSource) { Ternary domainIdRecursiveListProject = new Ternary(domainId, recursive, null); accountMgr.buildACLSearchParameters(caller, id, accountName, null, permittedAccounts, domainIdRecursiveListProject, listAll, false); domainId = domainIdRecursiveListProject.first(); @@ -761,6 +762,7 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q sb.and("domainId", sb.entity().getDomainId(), Op.EQ); sb.and("accountName", sb.entity().getAccountName(), Op.EQ); sb.and("state", sb.entity().getState(), Op.EQ); + sb.and("userSource", sb.entity().getSource(), Op.EQ); if (apiKeyAccess != null) { sb.and("apiKeyAccess", sb.entity().getApiKeyAccess(), Op.EQ); } @@ -827,6 +829,10 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q } } + if (userSource != null) { + sc.setParameters("userSource", userSource.toString()); + } + return _userAccountJoinDao.searchAndCount(sc, searchFilter); } diff --git a/server/src/test/java/com/cloud/api/query/QueryManagerImplTest.java b/server/src/test/java/com/cloud/api/query/QueryManagerImplTest.java index 42ea1ad4556..5bfb0553040 100644 --- a/server/src/test/java/com/cloud/api/query/QueryManagerImplTest.java +++ b/server/src/test/java/com/cloud/api/query/QueryManagerImplTest.java @@ -505,11 +505,13 @@ public class QueryManagerImplTest { Account.Type accountType = Account.Type.ADMIN; Long domainId = 1L; String apiKeyAccess = "Disabled"; + User.Source userSource = User.Source.NATIVE; Mockito.when(cmd.getUsername()).thenReturn(username); Mockito.when(cmd.getAccountName()).thenReturn(accountName); Mockito.when(cmd.getAccountType()).thenReturn(accountType); Mockito.when(cmd.getDomainId()).thenReturn(domainId); Mockito.when(cmd.getApiKeyAccess()).thenReturn(apiKeyAccess); + Mockito.when(cmd.getUserSource()).thenReturn(userSource); UserAccountJoinVO user = new UserAccountJoinVO(); DomainVO domain = Mockito.mock(DomainVO.class); @@ -531,6 +533,7 @@ public class QueryManagerImplTest { Mockito.verify(sc).setParameters("type", accountType); Mockito.verify(sc).setParameters("domainId", domainId); Mockito.verify(sc).setParameters("apiKeyAccess", false); + Mockito.verify(sc).setParameters("userSource", userSource.toString()); Mockito.verify(userAccountJoinDao, Mockito.times(1)).searchAndCount( any(SearchCriteria.class), any(Filter.class)); } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index e03aee00599..49f3246461f 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1320,6 +1320,7 @@ "label.lbprovider": "Load balancer provider", "label.lbruleid": "Load balancer ID", "label.lbtype": "Load balancer type", +"label.ldap": "LDAP", "label.ldap.configuration": "LDAP configuration", "label.ldap.group.name": "LDAP group", "label.level": "Level", @@ -1487,6 +1488,7 @@ "label.name": "Name", "label.name.optional": "Name (Optional)", "label.nat": "BigSwitch BCF NAT enabled", +"label.native": "Native", "label.ncc": "NCC", "label.netmask": "Netmask", "label.netscaler": "NetScaler", @@ -1984,7 +1986,9 @@ "label.s3.secret.key": "Secret key", "label.s3.socket.timeout": "Socket timeout", "label.s3.use.https": "Use HTTPS", +"label.saml": "SAML", "label.saml.disable": "SAML disable", +"label.saml.disabled": "SAML Disabled", "label.saml.enable": "SAML enable", "label.samlenable": "Authorize SAML SSO", "label.samlentity": "Identity provider", diff --git a/ui/src/components/view/DetailsTab.vue b/ui/src/components/view/DetailsTab.vue index f5f180c8f19..3622f87e67d 100644 --- a/ui/src/components/view/DetailsTab.vue +++ b/ui/src/components/view/DetailsTab.vue @@ -124,6 +124,9 @@ +
+ {{ $t(getUserSourceLabel(dataResource[item])) }} +
{{ dataResource[item] }}
@@ -406,6 +409,15 @@ export default { }) return resources + }, + getUserSourceLabel (source) { + if (source === 'saml2') { + source = 'saml' + } else if (source === 'saml2disabled') { + source = 'saml.disabled' + } + + return `label.${source}` } } } diff --git a/ui/src/components/view/SearchView.vue b/ui/src/components/view/SearchView.vue index a72039393cc..a32a0d1ecb5 100644 --- a/ui/src/components/view/SearchView.vue +++ b/ui/src/components/view/SearchView.vue @@ -306,7 +306,8 @@ export default { } if (['zoneid', 'domainid', 'imagestoreid', 'storageid', 'state', 'account', 'hypervisor', 'level', 'clusterid', 'podid', 'groupid', 'entitytype', 'accounttype', 'systemvmtype', 'scope', 'provider', - 'type', 'scope', 'managementserverid', 'serviceofferingid', 'diskofferingid', 'networkid', 'usagetype', 'restartrequired', 'guestiptype'].includes(item) + 'type', 'scope', 'managementserverid', 'serviceofferingid', 'diskofferingid', 'networkid', + 'usagetype', 'restartrequired', 'guestiptype', 'usersource'].includes(item) ) { type = 'list' } else if (item === 'tags') { @@ -435,6 +436,13 @@ export default { ] this.fields[apiKeyAccessIndex].loading = false } + + if (arrayField.includes('usersource')) { + const userSourceIndex = this.fields.findIndex(item => item.name === 'usersource') + this.fields[userSourceIndex].loading = true + this.fields[userSourceIndex].opts = this.fetchAvailableUserSourceTypes() + this.fields[userSourceIndex].loading = false + } }, async fetchDynamicFieldData (arrayField, searchKeyword) { const promises = [] @@ -1294,6 +1302,26 @@ export default { }) }) }, + fetchAvailableUserSourceTypes () { + return [ + { + id: 'native', + name: 'label.native' + }, + { + id: 'saml2', + name: 'label.saml' + }, + { + id: 'saml2disabled', + name: 'label.saml.disabled' + }, + { + id: 'ldap', + name: 'label.ldap' + } + ] + }, onSearch (value) { this.paramsFilter = {} this.searchQuery = value diff --git a/ui/src/config/section/user.js b/ui/src/config/section/user.js index 60a55973f8c..a18994fd6ce 100644 --- a/ui/src/config/section/user.js +++ b/ui/src/config/section/user.js @@ -17,6 +17,7 @@ import { shallowRef, defineAsyncComponent } from 'vue' import store from '@/store' +import { i18n } from '@/locales' export default { name: 'accountuser', @@ -26,13 +27,31 @@ export default { hidden: true, permission: ['listUsers'], searchFilters: () => { - var filters = [] + const filters = ['usersource'] if (store.getters.userInfo.roletype === 'Admin') { filters.push('apikeyaccess') } return filters }, - columns: ['username', 'state', 'firstname', 'lastname', 'email', 'account', 'domain'], + columns: [ + 'username', 'state', 'firstname', 'lastname', + 'email', 'account', 'domain', + { + field: 'userSource', + customTitle: 'userSource', + userSource: (record) => { + let { usersource: source } = record + + if (source === 'saml2') { + source = 'saml' + } else if (source === 'saml2disabled') { + source = 'saml.disabled' + } + + return i18n.global.t(`label.${source}`) + } + } + ], details: ['username', 'id', 'firstname', 'lastname', 'email', 'usersource', 'timezone', 'rolename', 'roletype', 'is2faenabled', 'account', 'domain', 'created'], tabs: [ { diff --git a/ui/src/views/iam/AddUser.vue b/ui/src/views/iam/AddUser.vue index 49bca327896..e922c404ca9 100644 --- a/ui/src/views/iam/AddUser.vue +++ b/ui/src/views/iam/AddUser.vue @@ -313,75 +313,79 @@ export default { isValidValueForKey (obj, key) { return key in obj && obj[key] != null }, - handleSubmit (e) { + async handleSubmit (e) { e.preventDefault() if (this.loading) return - this.formRef.value.validate().then(() => { - const values = toRaw(this.form) - this.loading = true - const params = { - username: values.username, - password: values.password, - email: values.email, - firstname: values.firstname, - lastname: values.lastname, - accounttype: 0 - } - if (this.account) { - params.account = this.account - } else if (this.accountList[values.account]) { - params.account = this.accountList[values.account].name - } + await this.formRef.value.validate() + .catch(error => this.formRef.value.scrollToField(error.errorFields[0].name)) - if (this.domainid) { - params.domainid = this.domainid - } else if (values.domainid) { - params.domainid = values.domainid - } - - if (this.isValidValueForKey(values, 'timezone') && values.timezone.length > 0) { - params.timezone = values.timezone - } - - api('createUser', {}, 'POST', params).then(response => { - this.$emit('refresh-data') - this.$notification.success({ - message: this.$t('label.create.user'), - description: `${this.$t('message.success.create.user')} ${params.username}` - }) - const user = response.createuserresponse.user - if (values.samlenable && user) { - api('authorizeSamlSso', { - enable: values.samlenable, - entityid: values.samlentity, - userid: user.id - }).then(response => { - this.$notification.success({ - message: this.$t('label.samlenable'), - description: this.$t('message.success.enable.saml.auth') - }) - }).catch(error => { - this.$notification.error({ - message: this.$t('message.request.failed'), - description: (error.response && error.response.headers && error.response.headers['x-description']) || error.message, - duration: 0 - }) - }) - } - this.closeAction() - }).catch(error => { - this.$notification.error({ - message: this.$t('message.request.failed'), - description: (error.response && error.response.headers && error.response.headers['x-description']) || error.message, - duration: 0 - }) - }).finally(() => { - this.loading = false + this.loading = true + const values = toRaw(this.form) + try { + const userCreationResponse = await this.createUser(values) + this.$notification.success({ + message: this.$t('label.create.user'), + description: `${this.$t('message.success.create.user')} ${values.username}` }) - }).catch(error => { - this.formRef.value.scrollToField(error.errorFields[0].name) - }) + + const user = userCreationResponse?.createuserresponse?.user + if (values.samlenable && user) { + await api('authorizeSamlSso', { + enable: values.samlenable, + entityid: values.samlentity, + userid: user.id + }) + this.$notification.success({ + message: this.$t('label.samlenable'), + description: this.$t('message.success.enable.saml.auth') + }) + } + + this.closeAction() + this.$emit('refresh-data') + } catch (error) { + if (error?.config?.params?.command === 'authorizeSamlSso') { + this.closeAction() + this.$emit('refresh-data') + } + + this.$notification.error({ + message: this.$t('message.request.failed'), + description: error?.response?.headers['x-description'] || error.message, + duration: 0 + }) + } finally { + this.loading = false + } + }, + async createUser (rawParams) { + const params = { + username: rawParams.username, + password: rawParams.password, + email: rawParams.email, + firstname: rawParams.firstname, + lastname: rawParams.lastname, + accounttype: 0 + } + + if (this.account) { + params.account = this.account + } else if (this.accountList[rawParams.account]) { + params.account = this.accountList[rawParams.account].name + } + + if (this.domainid) { + params.domainid = this.domainid + } else if (rawParams.domainid) { + params.domainid = rawParams.domainid + } + + if (this.isValidValueForKey(rawParams, 'timezone') && rawParams.timezone.length > 0) { + params.timezone = rawParams.timezone + } + + return api('createUser', {}, 'POST', params) }, async validateConfirmPassword (rule, value) { if (!value || value.length === 0) {