// This file is part of MinIO Console Server // Copyright (c) 2021 MinIO, Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package api import ( "context" "fmt" "sort" "strings" "github.com/minio/console/pkg/utils" "github.com/go-openapi/errors" "github.com/go-openapi/runtime/middleware" "github.com/minio/console/api/operations" accountApi "github.com/minio/console/api/operations/account" bucketApi "github.com/minio/console/api/operations/bucket" userApi "github.com/minio/console/api/operations/user" "github.com/minio/console/models" "github.com/minio/madmin-go/v3" iampolicy "github.com/minio/pkg/v3/policy" ) // Policy evaluated constants const ( Unknown = 0 Allow = 1 Deny = -1 ) func registerUsersHandlers(api *operations.ConsoleAPI) { // List Users api.UserListUsersHandler = userApi.ListUsersHandlerFunc(func(params userApi.ListUsersParams, session *models.Principal) middleware.Responder { listUsersResponse, err := getListUsersResponse(session, params) if err != nil { return userApi.NewListUsersDefault(err.Code).WithPayload(err.APIError) } return userApi.NewListUsersOK().WithPayload(listUsersResponse) }) // Add User api.UserAddUserHandler = userApi.AddUserHandlerFunc(func(params userApi.AddUserParams, session *models.Principal) middleware.Responder { userResponse, err := getUserAddResponse(session, params) if err != nil { return userApi.NewAddUserDefault(err.Code).WithPayload(err.APIError) } return userApi.NewAddUserCreated().WithPayload(userResponse) }) // Remove User api.UserRemoveUserHandler = userApi.RemoveUserHandlerFunc(func(params userApi.RemoveUserParams, session *models.Principal) middleware.Responder { err := getRemoveUserResponse(session, params) if err != nil { return userApi.NewRemoveUserDefault(err.Code).WithPayload(err.APIError) } return userApi.NewRemoveUserNoContent() }) // Update User-Groups api.UserUpdateUserGroupsHandler = userApi.UpdateUserGroupsHandlerFunc(func(params userApi.UpdateUserGroupsParams, session *models.Principal) middleware.Responder { userUpdateResponse, err := getUpdateUserGroupsResponse(session, params) if err != nil { return userApi.NewUpdateUserGroupsDefault(err.Code).WithPayload(err.APIError) } return userApi.NewUpdateUserGroupsOK().WithPayload(userUpdateResponse) }) // Get User api.UserGetUserInfoHandler = userApi.GetUserInfoHandlerFunc(func(params userApi.GetUserInfoParams, session *models.Principal) middleware.Responder { userInfoResponse, err := getUserInfoResponse(session, params) if err != nil { return userApi.NewGetUserInfoDefault(err.Code).WithPayload(err.APIError) } return userApi.NewGetUserInfoOK().WithPayload(userInfoResponse) }) // Update User api.UserUpdateUserInfoHandler = userApi.UpdateUserInfoHandlerFunc(func(params userApi.UpdateUserInfoParams, session *models.Principal) middleware.Responder { userUpdateResponse, err := getUpdateUserResponse(session, params) if err != nil { return userApi.NewUpdateUserInfoDefault(err.Code).WithPayload(err.APIError) } return userApi.NewUpdateUserInfoOK().WithPayload(userUpdateResponse) }) // Update User-Groups Bulk api.UserBulkUpdateUsersGroupsHandler = userApi.BulkUpdateUsersGroupsHandlerFunc(func(params userApi.BulkUpdateUsersGroupsParams, session *models.Principal) middleware.Responder { err := getAddUsersListToGroupsResponse(session, params) if err != nil { return userApi.NewBulkUpdateUsersGroupsDefault(err.Code).WithPayload(err.APIError) } return userApi.NewBulkUpdateUsersGroupsOK() }) api.BucketListUsersWithAccessToBucketHandler = bucketApi.ListUsersWithAccessToBucketHandlerFunc(func(params bucketApi.ListUsersWithAccessToBucketParams, session *models.Principal) middleware.Responder { response, err := getListUsersWithAccessToBucketResponse(session, params) if err != nil { return bucketApi.NewListUsersWithAccessToBucketDefault(err.Code).WithPayload(err.APIError) } return bucketApi.NewListUsersWithAccessToBucketOK().WithPayload(response) }) // Change User Password api.AccountChangeUserPasswordHandler = accountApi.ChangeUserPasswordHandlerFunc(func(params accountApi.ChangeUserPasswordParams, session *models.Principal) middleware.Responder { err := getChangeUserPasswordResponse(session, params) if err != nil { return accountApi.NewChangeUserPasswordDefault(err.Code).WithPayload(err.APIError) } return accountApi.NewChangeUserPasswordCreated() }) // Check number of Service Accounts for listed users api.UserCheckUserServiceAccountsHandler = userApi.CheckUserServiceAccountsHandlerFunc(func(params userApi.CheckUserServiceAccountsParams, session *models.Principal) middleware.Responder { userSAList, err := getCheckUserSAResponse(session, params) if err != nil { return userApi.NewCheckUserServiceAccountsDefault(err.Code).WithPayload(err.APIError) } return userApi.NewCheckUserServiceAccountsOK().WithPayload(userSAList) }) } func listUsers(ctx context.Context, client MinioAdmin) ([]*models.User, error) { // Get list of all users in the MinIO // This call requires explicit authentication, no anonymous requests are // allowed for listing users. userMap, err := client.listUsers(ctx) if err != nil { return []*models.User{}, err } var users []*models.User for accessKey, user := range userMap { userElem := &models.User{ AccessKey: accessKey, Status: string(user.Status), Policy: strings.Split(user.PolicyName, ","), MemberOf: user.MemberOf, } users = append(users, userElem) } return users, nil } // getListUsersResponse performs listUsers() and serializes it to the handler's output func getListUsersResponse(session *models.Principal, params userApi.ListUsersParams) (*models.ListUsersResponse, *CodedAPIError) { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return nil, ErrorWithContext(ctx, err) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} users, err := listUsers(ctx, adminClient) if err != nil { return nil, ErrorWithContext(ctx, err) } // serialize output listUsersResponse := &models.ListUsersResponse{ Users: users, } return listUsersResponse, nil } // addUser invokes adding a users on `MinioAdmin` and builds the response `models.User` func addUser(ctx context.Context, client MinioAdmin, accessKey, secretKey *string, groups []string, policies []string) (*models.User, error) { // Calls into MinIO to add a new user if there's an errors return it if err := client.addUser(ctx, *accessKey, *secretKey); err != nil { return nil, err } // set groups for the newly created user var userWithGroups *models.User if len(groups) > 0 { var errUG error userWithGroups, errUG = updateUserGroups(ctx, client, *accessKey, groups) if errUG != nil { return nil, errUG } } // set policies for the newly created user if len(policies) > 0 { policyString := strings.Join(policies, ",") if err := SetPolicy(ctx, client, policyString, *accessKey, "user"); err != nil { return nil, err } } memberOf := []string{} status := "enabled" if userWithGroups != nil { memberOf = userWithGroups.MemberOf status = userWithGroups.Status } userRet := &models.User{ AccessKey: *accessKey, MemberOf: memberOf, Policy: policies, Status: status, } return userRet, nil } func getUserAddResponse(session *models.Principal, params userApi.AddUserParams) (*models.User, *CodedAPIError) { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return nil, ErrorWithContext(ctx, err) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} var userExists bool _, err = adminClient.getUserInfo(ctx, *params.Body.AccessKey) userExists = err == nil if userExists { return nil, ErrorWithContext(ctx, ErrNonUniqueAccessKey) } user, err := addUser( ctx, adminClient, params.Body.AccessKey, params.Body.SecretKey, params.Body.Groups, params.Body.Policies, ) if err != nil { return nil, ErrorWithContext(ctx, err) } return user, nil } // removeUser invokes removing an user on `MinioAdmin`, then we return the response from API func removeUser(ctx context.Context, client MinioAdmin, accessKey string) error { return client.removeUser(ctx, accessKey) } func getRemoveUserResponse(session *models.Principal, params userApi.RemoveUserParams) *CodedAPIError { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return ErrorWithContext(ctx, err) } userName, err := utils.DecodeBase64(params.Name) if err != nil { return ErrorWithContext(ctx, err) } if session.AccountAccessKey == userName { return ErrorWithContext(ctx, ErrAvoidSelfAccountDelete) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} if err := removeUser(ctx, adminClient, userName); err != nil { return ErrorWithContext(ctx, err) } return nil } // getUserInfo calls MinIO server get the User Information func getUserInfo(ctx context.Context, client MinioAdmin, accessKey string) (*madmin.UserInfo, error) { userInfo, err := client.getUserInfo(ctx, accessKey) if err != nil { return nil, err } return &userInfo, nil } func getUserInfoResponse(session *models.Principal, params userApi.GetUserInfoParams) (*models.User, *CodedAPIError) { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return nil, ErrorWithContext(ctx, err) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} userName, err := utils.DecodeBase64(params.Name) if err != nil { return nil, ErrorWithContext(ctx, err) } user, err := getUserInfo(ctx, adminClient, userName) if err != nil { // User doesn't exist, return 404 if madmin.ToErrorResponse(err).Code == "XMinioAdminNoSuchUser" { errorCode := 404 errorMessage := "User doesn't exist" return nil, &CodedAPIError{Code: errorCode, APIError: &models.APIError{Message: errorMessage, DetailedMessage: err.Error()}} } return nil, ErrorWithContext(ctx, err) } var policies []string if user.PolicyName == "" { policies = []string{} } else { policies = strings.Split(user.PolicyName, ",") } hasPolicy := true if len(policies) == 0 { hasPolicy = false for i := 0; i < len(user.MemberOf); i++ { group, err := adminClient.getGroupDescription(ctx, user.MemberOf[i]) if err != nil { continue } if group.Policy != "" { hasPolicy = true break } } } userInformation := &models.User{ AccessKey: userName, MemberOf: user.MemberOf, Policy: policies, Status: string(user.Status), HasPolicy: hasPolicy, } return userInformation, nil } // updateUserGroups invokes getUserInfo() to get the old groups from the user, // then we merge the list with the new groups list to have a shorter iteration between groups and we do a comparison between the current and old groups. // We delete or update the groups according the location in each list and send the user with the new groups from `MinioAdmin` to the client func updateUserGroups(ctx context.Context, client MinioAdmin, user string, groupsToAssign []string) (*models.User, error) { parallelUserUpdate := func(groupName string, originGroups []string) chan error { chProcess := make(chan error) go func() error { defer close(chProcess) // Compare if groupName is in the arrays isGroupPersistent := IsElementInArray(groupsToAssign, groupName) isInOriginGroups := IsElementInArray(originGroups, groupName) if isGroupPersistent && isInOriginGroups { // Group is already assigned and doesn't need to be updated chProcess <- nil return nil } isRemove := false // User is added by default // User is deleted from the group if !isGroupPersistent { isRemove = true } userToAddRemove := []string{user} updateReturn := updateGroupMembers(ctx, client, groupName, userToAddRemove, isRemove) chProcess <- updateReturn return updateReturn }() return chProcess } userInfoOr, err := getUserInfo(ctx, client, user) if err != nil { return nil, err } memberOf := userInfoOr.MemberOf mergedGroupArray := UniqueKeys(append(memberOf, groupsToAssign...)) var listOfUpdates []chan error // Each group must be updated individually because there is no way to update all the groups at once for a user, // we are using the same logic as 'mc admin group add' command for _, groupN := range mergedGroupArray { proc := parallelUserUpdate(groupN, memberOf) listOfUpdates = append(listOfUpdates, proc) } channelHasError := false for _, chanRet := range listOfUpdates { locError := <-chanRet if locError != nil { channelHasError = true } } if channelHasError { errRt := errors.New(500, "there was an error updating the groups") return nil, errRt } userInfo, err := getUserInfo(ctx, client, user) if err != nil { return nil, err } policies := strings.Split(userInfo.PolicyName, ",") userReturn := &models.User{ AccessKey: user, MemberOf: userInfo.MemberOf, Policy: policies, Status: string(userInfo.Status), } return userReturn, nil } func getUpdateUserGroupsResponse(session *models.Principal, params userApi.UpdateUserGroupsParams) (*models.User, *CodedAPIError) { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return nil, ErrorWithContext(ctx, err) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} userName, err := utils.DecodeBase64(params.Name) if err != nil { return nil, ErrorWithContext(ctx, err) } user, err := updateUserGroups(ctx, adminClient, userName, params.Body.Groups) if err != nil { return nil, ErrorWithContext(ctx, err) } return user, nil } // setUserStatus invokes setUserStatus from madmin to update user status func setUserStatus(ctx context.Context, client MinioAdmin, user string, status string) error { var setStatus madmin.AccountStatus switch status { case "enabled": setStatus = madmin.AccountEnabled case "disabled": setStatus = madmin.AccountDisabled default: return errors.New(500, "status not valid") } return client.setUserStatus(ctx, user, setStatus) } func getUpdateUserResponse(session *models.Principal, params userApi.UpdateUserInfoParams) (*models.User, *CodedAPIError) { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return nil, ErrorWithContext(ctx, err) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} userName, err := utils.DecodeBase64(params.Name) if err != nil { return nil, ErrorWithContext(ctx, err) } status := *params.Body.Status groups := params.Body.Groups if err := setUserStatus(ctx, adminClient, userName, status); err != nil { return nil, ErrorWithContext(ctx, err) } userElem, errUG := updateUserGroups(ctx, adminClient, userName, groups) if errUG != nil { return nil, ErrorWithContext(ctx, errUG) } return userElem, nil } // addUsersListToGroups iterates over the user list & assigns the requested groups to each user. func addUsersListToGroups(ctx context.Context, client MinioAdmin, usersToUpdate []string, groupsToAssign []string) error { // We update each group with the complete usersList parallelGroupsUpdate := func(groupToAssign string) chan error { groupProcess := make(chan error) go func() { defer close(groupProcess) // We add the users array to the group. err := updateGroupMembers(ctx, client, groupToAssign, usersToUpdate, false) groupProcess <- err }() return groupProcess } var groupsUpdateList []chan error // We get each group name & add users accordingly for _, groupName := range groupsToAssign { // We update the group proc := parallelGroupsUpdate(groupName) groupsUpdateList = append(groupsUpdateList, proc) } errorsList := []string{} // We get the errors list because we want to have all errors at once. for _, err := range groupsUpdateList { errorFromUpdate := <-err // We store the errors to avoid Data Race if errorFromUpdate != nil { // If there is an errors, we store the errors strings so we can join them after we receive all errors errorsList = append(errorsList, errorFromUpdate.Error()) // We wait until all the channels have been closed. } } // If there are errors, we throw the final errors with the errors inside if len(errorsList) > 0 { errGen := fmt.Errorf("error in users-groups assignation: %q", strings.Join(errorsList, ",")) return errGen } return nil } func getAddUsersListToGroupsResponse(session *models.Principal, params userApi.BulkUpdateUsersGroupsParams) *CodedAPIError { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return ErrorWithContext(ctx, err) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} usersList := params.Body.Users groupsList := params.Body.Groups if err := addUsersListToGroups(ctx, adminClient, usersList, groupsList); err != nil { return ErrorWithContext(ctx, err) } return nil } func getListUsersWithAccessToBucketResponse(session *models.Principal, params bucketApi.ListUsersWithAccessToBucketParams) ([]string, *CodedAPIError) { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return nil, ErrorWithContext(ctx, err) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} list, err := listUsersWithAccessToBucket(ctx, adminClient, params.Bucket) if err != nil { return nil, ErrorWithContext(ctx, err) } return list, nil } func policyAllowsAndMatchesBucket(policy *iampolicy.Policy, bucket string) int { policyStatements := policy.Statements for i := 0; i < len(policyStatements); i++ { resources := policyStatements[i].Resources effect := policyStatements[i].Effect if resources.Match(bucket, map[string][]string{}) { if effect.IsValid() { if effect.IsAllowed(true) { return Allow } return Deny } } } return Unknown } func listUsersWithAccessToBucket(ctx context.Context, adminClient MinioAdmin, bucket string) ([]string, error) { users, err := adminClient.listUsers(ctx) if err != nil { return nil, err } var retval []string akHasAccess := make(map[string]struct{}) akIsDenied := make(map[string]struct{}) for k, v := range users { for _, policyName := range strings.Split(v.PolicyName, ",") { policyName = strings.TrimSpace(policyName) if policyName == "" { continue } policy, err := adminClient.getPolicy(ctx, policyName) if err != nil { ErrorWithContext(ctx, fmt.Errorf("unable to fetch policy %s: %v", policyName, err)) continue } if _, ok := akIsDenied[k]; !ok { switch policyAllowsAndMatchesBucket(policy, bucket) { case Allow: if _, ok := akHasAccess[k]; !ok { akHasAccess[k] = struct{}{} } case Deny: akIsDenied[k] = struct{}{} delete(akHasAccess, k) } } } } groups, err := adminClient.listGroups(ctx) if err != nil { ErrorWithContext(ctx, fmt.Errorf("unable to list groups: %v", err)) return retval, nil } for _, groupName := range groups { info, err := groupInfo(ctx, adminClient, groupName) if err != nil { ErrorWithContext(ctx, fmt.Errorf("unable to fetch group info %s: %v", groupName, err)) continue } policy, err := adminClient.getPolicy(ctx, info.Policy) if err != nil { ErrorWithContext(ctx, fmt.Errorf("unable to fetch group policy %s: %v", info.Policy, err)) continue } for _, member := range info.Members { if _, ok := akIsDenied[member]; !ok { switch policyAllowsAndMatchesBucket(policy, bucket) { case Allow: if _, ok := akHasAccess[member]; !ok { akHasAccess[member] = struct{}{} } case Deny: akIsDenied[member] = struct{}{} delete(akHasAccess, member) } } } } for k := range akHasAccess { retval = append(retval, k) } sort.Strings(retval) return retval, nil } // changeUserPassword changes password of selectedUser to newSecretKey func changeUserPassword(ctx context.Context, client MinioAdmin, selectedUser string, newSecretKey string) error { return client.changePassword(ctx, selectedUser, newSecretKey) } // getChangeUserPasswordResponse will change the password of selctedUser to newSecretKey func getChangeUserPasswordResponse(session *models.Principal, params accountApi.ChangeUserPasswordParams) *CodedAPIError { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return ErrorWithContext(ctx, err) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} // params will contain selectedUser and newSecretKey credentials for the user user := *params.Body.SelectedUser newSecretKey := *params.Body.NewSecretKey // changes password of user to newSecretKey if err := changeUserPassword(ctx, adminClient, user, newSecretKey); err != nil { return ErrorWithContext(ctx, err) } return nil } func getCheckUserSAResponse(session *models.Principal, params userApi.CheckUserServiceAccountsParams) (*models.UserServiceAccountSummary, *CodedAPIError) { ctx, cancel := context.WithCancel(params.HTTPRequest.Context()) defer cancel() mAdmin, err := NewMinioAdminClient(params.HTTPRequest.Context(), session) if err != nil { return nil, ErrorWithContext(ctx, err) } // create a minioClient interface implementation // defining the client to be used adminClient := AdminClient{Client: mAdmin} var userServiceAccountList []*models.UserServiceAccountItem hasSA := false for _, user := range params.SelectedUsers { listServAccs, err := adminClient.listServiceAccounts(ctx, user) if err != nil { return nil, ErrorWithContext(ctx, err) } numSAs := int64(len(listServAccs.Accounts)) if numSAs > 0 { hasSA = true } userAccountItem := &models.UserServiceAccountItem{ UserName: user, NumSAs: numSAs, } userServiceAccountList = append(userServiceAccountList, userAccountItem) } userAccountList := &models.UserServiceAccountSummary{ UserServiceAccountList: userServiceAccountList, HasSA: hasSA, } return userAccountList, nil }