diff --git a/cmd/admin-handlers.go b/cmd/admin-handlers.go index fed54fb3a..25c00d99b 100644 --- a/cmd/admin-handlers.go +++ b/cmd/admin-handlers.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "io" + "io/ioutil" "net/http" "os" "sort" @@ -1019,6 +1020,130 @@ func (a adminAPIHandlers) ListUsers(w http.ResponseWriter, r *http.Request) { writeSuccessResponseJSON(w, econfigData) } +// UpdateGroupMembers - PUT /minio/admin/v1/update-group-members +func (a adminAPIHandlers) UpdateGroupMembers(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "UpdateGroupMembers") + + objectAPI := validateAdminReq(ctx, w, r) + if objectAPI == nil { + return + } + + defer r.Body.Close() + data, err := ioutil.ReadAll(r.Body) + if err != nil { + writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidRequest), r.URL) + return + } + + var updReq madmin.GroupAddRemove + err = json.Unmarshal(data, &updReq) + if err != nil { + writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidRequest), r.URL) + return + } + + if updReq.IsRemove { + err = globalIAMSys.RemoveUsersFromGroup(updReq.Group, updReq.Members) + } else { + err = globalIAMSys.AddUsersToGroup(updReq.Group, updReq.Members) + } + + if err != nil { + writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) + return + } + + // Notify all other MinIO peers to load group. + for _, nerr := range globalNotificationSys.LoadGroup(updReq.Group) { + if nerr.Err != nil { + logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String()) + logger.LogIf(ctx, nerr.Err) + } + } +} + +// GetGroup - /minio/admin/v1/group?group=mygroup1 +func (a adminAPIHandlers) GetGroup(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "GetGroup") + + objectAPI := validateAdminReq(ctx, w, r) + if objectAPI == nil { + return + } + + vars := mux.Vars(r) + group := vars["group"] + + gdesc, err := globalIAMSys.GetGroupDescription(group) + if err != nil { + writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) + return + } + + body, err := json.Marshal(gdesc) + if err != nil { + writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) + return + } + + writeSuccessResponseJSON(w, body) +} + +// ListGroups - GET /minio/admin/v1/groups +func (a adminAPIHandlers) ListGroups(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "ListGroups") + + objectAPI := validateAdminReq(ctx, w, r) + if objectAPI == nil { + return + } + + groups := globalIAMSys.ListGroups() + body, err := json.Marshal(groups) + if err != nil { + writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) + return + } + + writeSuccessResponseJSON(w, body) +} + +// SetGroupStatus - PUT /minio/admin/v1/set-group-status?group=mygroup1&status=enabled +func (a adminAPIHandlers) SetGroupStatus(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "SetGroupStatus") + + objectAPI := validateAdminReq(ctx, w, r) + if objectAPI == nil { + return + } + + vars := mux.Vars(r) + group := vars["group"] + status := vars["status"] + + var err error + if status == statusEnabled { + err = globalIAMSys.SetGroupStatus(group, true) + } else if status == statusDisabled { + err = globalIAMSys.SetGroupStatus(group, false) + } else { + err = errInvalidArgument + } + if err != nil { + writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) + return + } + + // Notify all other MinIO peers to reload user. + for _, nerr := range globalNotificationSys.LoadGroup(group) { + if nerr.Err != nil { + logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String()) + logger.LogIf(ctx, nerr.Err) + } + } +} + // SetUserStatus - PUT /minio/admin/v1/set-user-status?accessKey=&status=[enabled|disabled] func (a adminAPIHandlers) SetUserStatus(w http.ResponseWriter, r *http.Request) { ctx := newContext(r, w, "SetUserStatus") @@ -1253,7 +1378,7 @@ func (a adminAPIHandlers) SetUserPolicy(w http.ResponseWriter, r *http.Request) return } - if err := globalIAMSys.PolicyDBSet(accessKey, policyName); err != nil { + if err := globalIAMSys.PolicyDBSet(accessKey, policyName, false); err != nil { writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) } diff --git a/cmd/admin-router.go b/cmd/admin-router.go index 63bbaaaeb..9ff9d4eb3 100644 --- a/cmd/admin-router.go +++ b/cmd/admin-router.go @@ -110,6 +110,18 @@ func registerAdminRouter(router *mux.Router, enableConfigOps, enableIAMOps bool) // List users adminV1Router.Methods(http.MethodGet).Path("/list-users").HandlerFunc(httpTraceHdrs(adminAPI.ListUsers)) + // Add/Remove members from group + adminV1Router.Methods(http.MethodPut).Path("/update-group-members").HandlerFunc(httpTraceHdrs(adminAPI.UpdateGroupMembers)) + + // Get Group + adminV1Router.Methods(http.MethodGet).Path("/group").HandlerFunc(httpTraceHdrs(adminAPI.GetGroup)).Queries("group", "{group:.*}") + + // List Groups + adminV1Router.Methods(http.MethodGet).Path("/groups").HandlerFunc(httpTraceHdrs(adminAPI.ListGroups)) + + // Set Group Status + adminV1Router.Methods(http.MethodPut).Path("/set-group-status").HandlerFunc(httpTraceHdrs(adminAPI.SetGroupStatus)).Queries("group", "{group:.*}").Queries("status", "{status:.*}") + // List policies adminV1Router.Methods(http.MethodGet).Path("/list-canned-policies").HandlerFunc(httpTraceHdrs(adminAPI.ListCannedPolicies)) } diff --git a/cmd/api-errors.go b/cmd/api-errors.go index 2bac43536..a929da94d 100644 --- a/cmd/api-errors.go +++ b/cmd/api-errors.go @@ -203,6 +203,8 @@ const ( ErrMalformedJSON ErrAdminNoSuchUser + ErrAdminNoSuchGroup + ErrAdminGroupNotEmpty ErrAdminNoSuchPolicy ErrAdminInvalidArgument ErrAdminInvalidAccessKey @@ -923,6 +925,16 @@ var errorCodes = errorCodeMap{ Description: "The specified user does not exist.", HTTPStatusCode: http.StatusNotFound, }, + ErrAdminNoSuchGroup: { + Code: "XMinioAdminNoSuchGroup", + Description: "The specified group does not exist.", + HTTPStatusCode: http.StatusNotFound, + }, + ErrAdminGroupNotEmpty: { + Code: "XMinioAdminGroupNotEmpty", + Description: "The specified group is not empty - cannot remove it.", + HTTPStatusCode: http.StatusBadRequest, + }, ErrAdminNoSuchPolicy: { Code: "XMinioAdminNoSuchPolicy", Description: "The canned policy does not exist.", @@ -1500,6 +1512,10 @@ func toAPIErrorCode(ctx context.Context, err error) (apiErr APIErrorCode) { apiErr = ErrAdminInvalidArgument case errNoSuchUser: apiErr = ErrAdminNoSuchUser + case errNoSuchGroup: + apiErr = ErrAdminNoSuchGroup + case errGroupNotEmpty: + apiErr = ErrAdminGroupNotEmpty case errNoSuchPolicy: apiErr = ErrAdminNoSuchPolicy case errSignatureMismatch: diff --git a/cmd/iam.go b/cmd/iam.go index 84d78b8b1..177f7c95b 100644 --- a/cmd/iam.go +++ b/cmd/iam.go @@ -42,6 +42,9 @@ const ( // IAM users directory. iamConfigUsersPrefix = iamConfigPrefix + "/users/" + // IAM groups directory. + iamConfigGroupsPrefix = iamConfigPrefix + "/groups/" + // IAM policies directory. iamConfigPoliciesPrefix = iamConfigPrefix + "/policies/" @@ -52,6 +55,7 @@ const ( iamConfigPolicyDBPrefix = iamConfigPrefix + "/policydb/" iamConfigPolicyDBUsersPrefix = iamConfigPolicyDBPrefix + "users/" iamConfigPolicyDBSTSUsersPrefix = iamConfigPolicyDBPrefix + "sts-users/" + iamConfigPolicyDBGroupsPrefix = iamConfigPolicyDBPrefix + "groups/" // IAM identity file which captures identity credentials. iamIdentityFile = "identity.json" @@ -59,12 +63,20 @@ const ( // IAM policy file which provides policies for each users. iamPolicyFile = "policy.json" + // IAM group members file + iamGroupMembersFile = "members.json" + // IAM format file iamFormatFile = "format.json" iamFormatVersion1 = 1 ) +const ( + statusEnabled = "enabled" + statusDisabled = "disabled" +) + type iamFormat struct { Version int `json:"version"` } @@ -85,25 +97,23 @@ func getUserIdentityPath(user string, isSTS bool) string { return pathJoin(basePath, user, iamIdentityFile) } +func getGroupInfoPath(group string) string { + return pathJoin(iamConfigGroupsPrefix, group, iamGroupMembersFile) +} + func getPolicyDocPath(name string) string { return pathJoin(iamConfigPoliciesPrefix, name, iamPolicyFile) } -func getMappedPolicyPath(name string, isSTS bool) string { - if isSTS { +func getMappedPolicyPath(name string, isSTS, isGroup bool) string { + switch { + case isSTS: return pathJoin(iamConfigPolicyDBSTSUsersPrefix, name+".json") + case isGroup: + return pathJoin(iamConfigPolicyDBGroupsPrefix, name+".json") + default: + return pathJoin(iamConfigPolicyDBUsersPrefix, name+".json") } - return pathJoin(iamConfigPolicyDBUsersPrefix, name+".json") -} - -// MappedPolicy represents a policy name mapped to a user or group -type MappedPolicy struct { - Version int `json:"version"` - Policy string `json:"policy"` -} - -func newMappedPolicy(policy string) MappedPolicy { - return MappedPolicy{Version: 1, Policy: policy} } // UserIdentity represents a user's secret key and their status @@ -116,6 +126,27 @@ func newUserIdentity(creds auth.Credentials) UserIdentity { return UserIdentity{Version: 1, Credentials: creds} } +// GroupInfo contains info about a group +type GroupInfo struct { + Version int `json:"version"` + Status string `json:"status"` + Members []string `json:"members"` +} + +func newGroupInfo(members []string) GroupInfo { + return GroupInfo{Version: 1, Status: statusEnabled, Members: members} +} + +// MappedPolicy represents a policy name mapped to a user or group +type MappedPolicy struct { + Version int `json:"version"` + Policy string `json:"policy"` +} + +func newMappedPolicy(policy string) MappedPolicy { + return MappedPolicy{Version: 1, Policy: policy} +} + func loadIAMConfigItem(objectAPI ObjectLayer, item interface{}, path string) error { data, err := readConfig(context.Background(), objectAPI, path) if err != nil { @@ -212,12 +243,18 @@ func saveIAMConfigItemEtcd(ctx context.Context, item interface{}, path string) e // IAMSys - config system. type IAMSys struct { sync.RWMutex - // map of usernames to credentials - iamUsersMap map[string]auth.Credentials // map of policy names to policy definitions iamPolicyDocsMap map[string]iampolicy.Policy + // map of usernames to credentials + iamUsersMap map[string]auth.Credentials + // map of group names to group info + iamGroupsMap map[string]GroupInfo + // map of user names to groups they are a member of + iamUserGroupMemberships map[string]set.StringSet // map of usernames/temporary access keys to policy names iamUserPolicyMap map[string]MappedPolicy + // map of group names to policy names + iamGroupPolicyMap map[string]MappedPolicy } func loadPolicyDoc(objectAPI ObjectLayer, policy string, m map[string]iampolicy.Policy) error { @@ -260,7 +297,7 @@ func loadUser(objectAPI ObjectLayer, user string, isSTS bool, idFile := getUserIdentityPath(user, isSTS) // Delete expired identity - ignoring errors here. deleteConfig(context.Background(), objectAPI, idFile) - deleteConfig(context.Background(), objectAPI, getMappedPolicyPath(user, isSTS)) + deleteConfig(context.Background(), objectAPI, getMappedPolicyPath(user, isSTS, false)) return nil } @@ -291,11 +328,38 @@ func loadUsers(objectAPI ObjectLayer, isSTS bool, m map[string]auth.Credentials) return nil } -func loadMappedPolicy(objectAPI ObjectLayer, name string, isSTS bool, +func loadGroup(objectAPI ObjectLayer, group string, m map[string]GroupInfo) error { + var g GroupInfo + err := loadIAMConfigItem(objectAPI, &g, getGroupInfoPath(group)) + if err != nil { + return err + } + m[group] = g + return nil +} + +func loadGroups(objectAPI ObjectLayer, m map[string]GroupInfo) error { + doneCh := make(chan struct{}) + defer close(doneCh) + for item := range listIAMConfigItems(objectAPI, iamConfigGroupsPrefix, true, doneCh) { + if item.Err != nil { + return item.Err + } + + group := item.Item + err := loadGroup(objectAPI, group, m) + if err != nil { + return err + } + } + return nil +} + +func loadMappedPolicy(objectAPI ObjectLayer, name string, isSTS, isGroup bool, m map[string]MappedPolicy) error { var p MappedPolicy - err := loadIAMConfigItem(objectAPI, &p, getMappedPolicyPath(name, isSTS)) + err := loadIAMConfigItem(objectAPI, &p, getMappedPolicyPath(name, isSTS, isGroup)) if err != nil { return err } @@ -303,7 +367,7 @@ func loadMappedPolicy(objectAPI ObjectLayer, name string, isSTS bool, return nil } -func loadMappedPolicies(objectAPI ObjectLayer, isSTS bool, m map[string]MappedPolicy) error { +func loadMappedPolicies(objectAPI ObjectLayer, isSTS, isGroup bool, m map[string]MappedPolicy) error { doneCh := make(chan struct{}) defer close(doneCh) basePath := iamConfigPolicyDBUsersPrefix @@ -317,7 +381,7 @@ func loadMappedPolicies(objectAPI ObjectLayer, isSTS bool, m map[string]MappedPo policyFile := item.Item userOrGroupName := strings.TrimSuffix(policyFile, ".json") - err := loadMappedPolicy(objectAPI, userOrGroupName, isSTS, m) + err := loadMappedPolicy(objectAPI, userOrGroupName, isSTS, isGroup, m) if err != nil { return err } @@ -325,6 +389,51 @@ func loadMappedPolicies(objectAPI ObjectLayer, isSTS bool, m map[string]MappedPo return nil } +// LoadGroup - loads a specific group from storage, and updates the +// memberships cache. If the specified group does not exist in +// storage, it is removed from in-memory maps as well - this +// simplifies the implementation for group removal. This is called +// only via IAM notifications. +func (sys *IAMSys) LoadGroup(objAPI ObjectLayer, group string) error { + if objAPI == nil { + return errInvalidArgument + } + + sys.Lock() + defer sys.Unlock() + + if globalEtcdClient != nil { + // Watch APIs cover this case, so nothing to do. + return nil + } + + err := loadGroup(objAPI, group, sys.iamGroupsMap) + if err != nil && err != errConfigNotFound { + return err + } + + if err == errConfigNotFound { + // group does not exist - so remove from memory. + sys.removeGroupFromMembershipsMap(group) + delete(sys.iamGroupsMap, group) + delete(sys.iamGroupPolicyMap, group) + return nil + } + + gi := sys.iamGroupsMap[group] + + // Updating the group memberships cache happens in two steps: + // + // 1. Remove the group from each user's list of memberships. + // 2. Add the group to each member's list of memberships. + // + // This ensures that regardless of members being added or + // removed, the cache stays current. + sys.removeGroupFromMembershipsMap(group) + sys.updateGroupMembershipsMap(group, &gi) + return nil +} + // LoadPolicy - reloads a specific canned policy from backend disks or etcd. func (sys *IAMSys) LoadPolicy(objAPI ObjectLayer, policyName string) error { if objAPI == nil { @@ -356,7 +465,7 @@ func (sys *IAMSys) LoadUser(objAPI ObjectLayer, accessKey string, isSTS bool) er if err != nil { return err } - err = loadMappedPolicy(objAPI, accessKey, isSTS, sys.iamUserPolicyMap) + err = loadMappedPolicy(objAPI, accessKey, isSTS, false, sys.iamUserPolicyMap) if err != nil { return err } @@ -378,6 +487,7 @@ func (sys *IAMSys) reloadFromEvent(event *etcd.Event) { eventCreate := event.IsModify() || event.IsCreate() eventDelete := event.Type == etcd.EventTypeDelete usersPrefix := strings.HasPrefix(string(event.Kv.Key), iamConfigUsersPrefix) + groupsPrefix := strings.HasPrefix(string(event.Kv.Key), iamConfigGroupsPrefix) stsPrefix := strings.HasPrefix(string(event.Kv.Key), iamConfigSTSPrefix) policyPrefix := strings.HasPrefix(string(event.Kv.Key), iamConfigPoliciesPrefix) policyDBUsersPrefix := strings.HasPrefix(string(event.Kv.Key), iamConfigPolicyDBUsersPrefix) @@ -398,6 +508,13 @@ func (sys *IAMSys) reloadFromEvent(event *etcd.Event) { accessKey := path.Dir(strings.TrimPrefix(string(event.Kv.Key), iamConfigSTSPrefix)) loadEtcdUser(ctx, accessKey, true, sys.iamUsersMap) + case groupsPrefix: + group := path.Dir(strings.TrimPrefix(string(event.Kv.Key), + iamConfigGroupsPrefix)) + loadEtcdGroup(ctx, group, sys.iamGroupsMap) + gi := sys.iamGroupsMap[group] + sys.removeGroupFromMembershipsMap(group) + sys.updateGroupMembershipsMap(group, &gi) case policyPrefix: policyName := path.Dir(strings.TrimPrefix(string(event.Kv.Key), iamConfigPoliciesPrefix)) @@ -406,12 +523,12 @@ func (sys *IAMSys) reloadFromEvent(event *etcd.Event) { policyMapFile := strings.TrimPrefix(string(event.Kv.Key), iamConfigPolicyDBUsersPrefix) user := strings.TrimSuffix(policyMapFile, ".json") - loadEtcdMappedPolicy(ctx, user, false, sys.iamUserPolicyMap) + loadEtcdMappedPolicy(ctx, user, false, false, sys.iamUserPolicyMap) case policyDBSTSUsersPrefix: policyMapFile := strings.TrimPrefix(string(event.Kv.Key), iamConfigPolicyDBSTSUsersPrefix) user := strings.TrimSuffix(policyMapFile, ".json") - loadEtcdMappedPolicy(ctx, user, true, sys.iamUserPolicyMap) + loadEtcdMappedPolicy(ctx, user, true, false, sys.iamUserPolicyMap) } case eventDelete: switch { @@ -423,6 +540,12 @@ func (sys *IAMSys) reloadFromEvent(event *etcd.Event) { accessKey := path.Dir(strings.TrimPrefix(string(event.Kv.Key), iamConfigSTSPrefix)) delete(sys.iamUsersMap, accessKey) + case groupsPrefix: + group := path.Dir(strings.TrimPrefix(string(event.Kv.Key), + iamConfigGroupsPrefix)) + sys.removeGroupFromMembershipsMap(group) + delete(sys.iamGroupsMap, group) + delete(sys.iamGroupPolicyMap, group) case policyPrefix: policyName := path.Dir(strings.TrimPrefix(string(event.Kv.Key), iamConfigPoliciesPrefix)) @@ -541,7 +664,7 @@ func migrateUsersConfigToV1(objAPI ObjectLayer, isSTS bool) error { // 2. copy policy file to new location. mp := newMappedPolicy(policyName) - path := getMappedPolicyPath(user, isSTS) + path := getMappedPolicyPath(user, isSTS, false) if err := saveIAMConfigItem(objAPI, mp, path); err != nil { return err } @@ -621,7 +744,7 @@ func migrateUsersConfigEtcdToV1(isSTS bool) error { // 2. copy policy to new loc. mp := newMappedPolicy(policyName) - path := getMappedPolicyPath(user, isSTS) + path := getMappedPolicyPath(user, isSTS, false) if err := saveIAMConfigItemEtcd(ctx, mp, path); err != nil { return err } @@ -898,7 +1021,7 @@ func (sys *IAMSys) DeleteUser(accessKey string) error { } var err error - mappingPath := getMappedPolicyPath(accessKey, false) + mappingPath := getMappedPolicyPath(accessKey, false, false) idPath := getUserIdentityPath(accessKey, false) if globalEtcdClient != nil { // It is okay to ignore errors when deleting policy.json for the user. @@ -950,7 +1073,7 @@ func (sys *IAMSys) SetTempUser(accessKey string, cred auth.Credentials, policyNa mp := newMappedPolicy(policyName) - mappingPath := getMappedPolicyPath(accessKey, true) + mappingPath := getMappedPolicyPath(accessKey, true, false) var err error if globalEtcdClient != nil { err = saveIAMConfigItemEtcd(context.Background(), mp, mappingPath) @@ -1072,7 +1195,7 @@ func (sys *IAMSys) SetUser(accessKey string, uinfo madmin.UserInfo) error { // Set policy if specified. if uinfo.PolicyName != "" { - return sys.policyDBSet(objectAPI, accessKey, uinfo.PolicyName, false) + return sys.policyDBSet(objectAPI, accessKey, uinfo.PolicyName, false, false) } return nil } @@ -1119,10 +1242,243 @@ func (sys *IAMSys) GetUser(accessKey string) (cred auth.Credentials, ok bool) { return cred, ok && cred.IsValid() } +// AddUsersToGroup - adds users to a group, creating the group if +// needed. No error if user(s) already are in the group. +func (sys *IAMSys) AddUsersToGroup(group string, members []string) error { + objectAPI := newObjectLayerFn() + if objectAPI == nil { + return errServerNotInitialized + } + + if group == "" { + return errInvalidArgument + } + + sys.Lock() + defer sys.Unlock() + + // Validate that all members exist. + for _, member := range members { + _, ok := sys.iamUsersMap[member] + if !ok { + return errNoSuchUser + } + } + + gi, ok := sys.iamGroupsMap[group] + if !ok { + // Set group as enabled by default when it doesn't + // exist. + gi = newGroupInfo(members) + } else { + mergedMembers := append(gi.Members, members...) + uniqMembers := set.CreateStringSet(mergedMembers...).ToSlice() + gi.Members = uniqMembers + } + + var err error + if globalEtcdClient == nil { + err = saveIAMConfigItem(objectAPI, gi, getGroupInfoPath(group)) + } else { + err = saveIAMConfigItemEtcd(context.Background(), gi, getGroupInfoPath(group)) + } + if err != nil { + return err + } + + sys.iamGroupsMap[group] = gi + + // update user-group membership map + for _, member := range members { + gset := sys.iamUserGroupMemberships[member] + if gset == nil { + gset = set.CreateStringSet(group) + } else { + gset.Add(group) + } + sys.iamUserGroupMemberships[member] = gset + } + + return nil +} + +// RemoveUsersFromGroup - remove users from group. If no users are +// given, and the group is empty, deletes the group as well. +func (sys *IAMSys) RemoveUsersFromGroup(group string, members []string) error { + objectAPI := newObjectLayerFn() + if objectAPI == nil { + return errServerNotInitialized + } + + if group == "" { + return errInvalidArgument + } + + sys.Lock() + defer sys.Unlock() + + // Validate that all members exist. + for _, member := range members { + _, ok := sys.iamUsersMap[member] + if !ok { + return errNoSuchUser + } + } + + gi, ok := sys.iamGroupsMap[group] + if !ok { + return errNoSuchGroup + } + + // Check if attempting to delete a non-empty group. + if len(members) == 0 && len(gi.Members) != 0 { + return errGroupNotEmpty + } + + if len(members) == 0 { + // len(gi.Members) == 0 here. + + // Remove the group from storage. + giPath := getGroupInfoPath(group) + giPolicyPath := getMappedPolicyPath(group, false, true) + if globalEtcdClient == nil { + err := deleteConfig(context.Background(), objectAPI, giPath) + if err != nil { + return err + } + + // Remove mapped policy from storage. + err = deleteConfig(context.Background(), objectAPI, + getMappedPolicyPath(group, false, true)) + if err != nil { + return err + } + } else { + err := deleteKeyEtcd(context.Background(), globalEtcdClient, giPath) + if err != nil { + return err + } + + err = deleteKeyEtcd(context.Background(), globalEtcdClient, giPolicyPath) + if err != nil { + return err + } + } + + // Delete from server memory + delete(sys.iamGroupsMap, group) + delete(sys.iamGroupPolicyMap, group) + return nil + } + + // Only removing members. + s := set.CreateStringSet(gi.Members...) + d := set.CreateStringSet(members...) + gi.Members = s.Difference(d).ToSlice() + + err := saveIAMConfigItem(objectAPI, gi, getGroupInfoPath(group)) + if err != nil { + return err + } + sys.iamGroupsMap[group] = gi + + // update user-group membership map + for _, member := range members { + gset := sys.iamUserGroupMemberships[member] + if gset == nil { + continue + } + gset.Remove(group) + sys.iamUserGroupMemberships[member] = gset + } + + return nil +} + +// SetGroupStatus - enable/disabled a group +func (sys *IAMSys) SetGroupStatus(group string, enabled bool) error { + objectAPI := newObjectLayerFn() + if objectAPI == nil { + return errServerNotInitialized + } + + sys.Lock() + defer sys.Unlock() + + if group == "" { + return errInvalidArgument + } + + gi, ok := sys.iamGroupsMap[group] + if !ok { + return errNoSuchGroup + } + + if enabled { + gi.Status = statusEnabled + } else { + gi.Status = statusDisabled + } + + var err error + if globalEtcdClient == nil { + err = saveIAMConfigItem(objectAPI, gi, getGroupInfoPath(group)) + } else { + err = saveIAMConfigItemEtcd(context.Background(), gi, getGroupInfoPath(group)) + } + if err != nil { + return err + } + + sys.iamGroupsMap[group] = gi + + return nil +} + +// GetGroupDescription - builds up group description +func (sys *IAMSys) GetGroupDescription(group string) (gd madmin.GroupDesc, err error) { + sys.RLock() + defer sys.RUnlock() + + gi, ok := sys.iamGroupsMap[group] + if !ok { + return gd, errNoSuchGroup + } + + var p []string + p, err = sys.policyDBGet(group, true) + if err != nil { + return gd, err + } + + policy := "" + if len(p) > 0 { + policy = p[0] + } + + return madmin.GroupDesc{ + Name: group, + Status: gi.Status, + Members: gi.Members, + Policy: policy, + }, nil +} + +// ListGroups - lists groups +func (sys *IAMSys) ListGroups() (r []string) { + sys.RLock() + defer sys.RUnlock() + + for k := range sys.iamGroupsMap { + r = append(r, k) + } + return r +} + // PolicyDBSet - sets a policy for a user or group in the // PolicyDB. This function applies only long-term users. For STS // users, policy is set directly by called sys.policyDBSet(). -func (sys *IAMSys) PolicyDBSet(name, policy string) error { +func (sys *IAMSys) PolicyDBSet(name, policy string, isGroup bool) error { objectAPI := newObjectLayerFn() if objectAPI == nil { return errServerNotInitialized @@ -1131,12 +1487,14 @@ func (sys *IAMSys) PolicyDBSet(name, policy string) error { sys.Lock() defer sys.Unlock() - return sys.policyDBSet(objectAPI, name, policy, false) + // isSTS is always false when called via PolicyDBSet as policy + // is never set by an external API call for STS users. + return sys.policyDBSet(objectAPI, name, policy, false, isGroup) } // policyDBSet - sets a policy for user in the policy db. Assumes that // caller has sys.Lock(). -func (sys *IAMSys) policyDBSet(objectAPI ObjectLayer, name, policy string, isSTS bool) error { +func (sys *IAMSys) policyDBSet(objectAPI ObjectLayer, name, policy string, isSTS, isGroup bool) error { if name == "" || policy == "" { return errInvalidArgument } @@ -1156,7 +1514,7 @@ func (sys *IAMSys) policyDBSet(objectAPI ObjectLayer, name, policy string, isSTS return errNoSuchUser } var err error - mappingPath := getMappedPolicyPath(name, isSTS) + mappingPath := getMappedPolicyPath(name, isSTS, isGroup) if globalEtcdClient != nil { err = saveIAMConfigItemEtcd(context.Background(), mp, mappingPath) } else { @@ -1169,27 +1527,57 @@ func (sys *IAMSys) policyDBSet(objectAPI ObjectLayer, name, policy string, isSTS return nil } -// PolicyDBGet - gets policy set on a user -func (sys *IAMSys) PolicyDBGet(name string) (string, error) { +// PolicyDBGet - gets policy set on a user or group. Since a user may +// be a member of multiple groups, this function returns an array of +// applicable policies (each group is mapped to at most one policy). +func (sys *IAMSys) PolicyDBGet(name string, isGroup bool) ([]string, error) { if name == "" { - return "", errInvalidArgument + return nil, errInvalidArgument } objectAPI := newObjectLayerFn() if objectAPI == nil { - return "", errServerNotInitialized + return nil, errServerNotInitialized } sys.RLock() defer sys.RUnlock() + return sys.policyDBGet(name, isGroup) +} + +// This call assumes that caller has the sys.RLock() +func (sys *IAMSys) policyDBGet(name string, isGroup bool) ([]string, error) { + if isGroup { + if _, ok := sys.iamGroupsMap[name]; !ok { + return nil, errNoSuchGroup + } + + policy := sys.iamGroupPolicyMap[name] + // returned policy could be empty + if policy.Policy == "" { + return nil, nil + } + return []string{policy.Policy}, nil + } + if _, ok := sys.iamUsersMap[name]; !ok { - return "", errNoSuchUser + return nil, errNoSuchUser } + result := []string{} policy := sys.iamUserPolicyMap[name] // returned policy could be empty - return policy.Policy, nil + if policy.Policy != "" { + result = append(result, policy.Policy) + } + for _, group := range sys.iamUserGroupMemberships[name].ToSlice() { + p, ok := sys.iamGroupPolicyMap[group] + if ok && p.Policy != "" { + result = append(result, p.Policy) + } + } + return result, nil } // IsAllowedSTS is meant for STS based temporary credentials, @@ -1326,11 +1714,11 @@ func etcdKvsToSetPolicyDB(prefix string, kvs []*mvccpb.KeyValue) set.StringSet { return items } -func loadEtcdMappedPolicy(ctx context.Context, name string, isSTS bool, +func loadEtcdMappedPolicy(ctx context.Context, name string, isSTS, isGroup bool, m map[string]MappedPolicy) error { var p MappedPolicy - err := loadIAMConfigItemEtcd(ctx, &p, getMappedPolicyPath(name, isSTS)) + err := loadIAMConfigItemEtcd(ctx, &p, getMappedPolicyPath(name, isSTS, isGroup)) if err != nil { return err } @@ -1338,7 +1726,7 @@ func loadEtcdMappedPolicy(ctx context.Context, name string, isSTS bool, return nil } -func loadEtcdMappedPolicies(isSTS bool, m map[string]MappedPolicy) error { +func loadEtcdMappedPolicies(isSTS, isGroup bool, m map[string]MappedPolicy) error { ctx, cancel := context.WithTimeout(context.Background(), defaultContextTimeout) defer cancel() basePrefix := iamConfigPolicyDBUsersPrefix @@ -1354,7 +1742,7 @@ func loadEtcdMappedPolicies(isSTS bool, m map[string]MappedPolicy) error { // Reload config and policies for all users. for _, user := range users.ToSlice() { - if err = loadEtcdMappedPolicy(ctx, user, isSTS, m); err != nil { + if err = loadEtcdMappedPolicy(ctx, user, isSTS, isGroup, m); err != nil { return err } } @@ -1371,7 +1759,7 @@ func loadEtcdUser(ctx context.Context, user string, isSTS bool, m map[string]aut if u.Credentials.IsExpired() { // Delete expired identity. deleteKeyEtcd(ctx, globalEtcdClient, getUserIdentityPath(user, isSTS)) - deleteKeyEtcd(ctx, globalEtcdClient, getMappedPolicyPath(user, isSTS)) + deleteKeyEtcd(ctx, globalEtcdClient, getMappedPolicyPath(user, isSTS, false)) return nil } @@ -1394,7 +1782,7 @@ func loadEtcdUsers(isSTS bool, m map[string]auth.Credentials) error { users := etcdKvsToSet(basePrefix, r.Kvs) - // Reload config and policies for all users. + // Reload config for all users. for _, user := range users.ToSlice() { if err = loadEtcdUser(ctx, user, isSTS, m); err != nil { return err @@ -1403,6 +1791,35 @@ func loadEtcdUsers(isSTS bool, m map[string]auth.Credentials) error { return nil } +func loadEtcdGroup(ctx context.Context, group string, m map[string]GroupInfo) error { + var gi GroupInfo + err := loadIAMConfigItemEtcd(ctx, &gi, getGroupInfoPath(group)) + if err != nil { + return err + } + m[group] = gi + return nil +} + +func loadEtcdGroups(m map[string]GroupInfo) error { + ctx, cancel := context.WithTimeout(context.Background(), defaultContextTimeout) + defer cancel() + r, err := globalEtcdClient.Get(ctx, iamConfigGroupsPrefix, etcd.WithPrefix(), etcd.WithKeysOnly()) + if err != nil { + return err + } + + groups := etcdKvsToSet(iamConfigGroupsPrefix, r.Kvs) + + // Reload config for all groups. + for _, group := range groups.ToSlice() { + if err = loadEtcdGroup(ctx, group, m); err != nil { + return err + } + } + return nil +} + func loadEtcdPolicy(ctx context.Context, policyName string, m map[string]iampolicy.Policy) error { var p iampolicy.Policy err := loadIAMConfigItemEtcd(ctx, &p, getPolicyDocPath(policyName)) @@ -1451,8 +1868,10 @@ func setDefaultCannedPolicies(policies map[string]iampolicy.Policy) { func (sys *IAMSys) refreshEtcd() error { iamUsersMap := make(map[string]auth.Credentials) + iamGroupsMap := make(map[string]GroupInfo) iamPolicyDocsMap := make(map[string]iampolicy.Policy) iamUserPolicyMap := make(map[string]MappedPolicy) + iamGroupPolicyMap := make(map[string]MappedPolicy) if err := loadEtcdPolicies(iamPolicyDocsMap); err != nil { return err @@ -1464,11 +1883,19 @@ func (sys *IAMSys) refreshEtcd() error { if err := loadEtcdUsers(true, iamUsersMap); err != nil { return err } - if err := loadEtcdMappedPolicies(false, iamUserPolicyMap); err != nil { + if err := loadEtcdGroups(iamGroupsMap); err != nil { + return err + } + + if err := loadEtcdMappedPolicies(false, false, iamUserPolicyMap); err != nil { return err } // load STS users policy mapping into the same map - if err := loadEtcdMappedPolicies(true, iamUserPolicyMap); err != nil { + if err := loadEtcdMappedPolicies(true, false, iamUserPolicyMap); err != nil { + return err + } + // load group policy mapping + if err := loadEtcdMappedPolicies(false, true, iamGroupPolicyMap); err != nil { return err } @@ -1479,8 +1906,10 @@ func (sys *IAMSys) refreshEtcd() error { defer sys.Unlock() sys.iamUsersMap = iamUsersMap + sys.iamGroupsMap = iamGroupsMap sys.iamUserPolicyMap = iamUserPolicyMap sys.iamPolicyDocsMap = iamPolicyDocsMap + sys.iamGroupPolicyMap = iamGroupPolicyMap return nil } @@ -1488,8 +1917,10 @@ func (sys *IAMSys) refreshEtcd() error { // Refresh IAMSys. func (sys *IAMSys) refresh(objAPI ObjectLayer) error { iamUsersMap := make(map[string]auth.Credentials) + iamGroupsMap := make(map[string]GroupInfo) iamPolicyDocsMap := make(map[string]iampolicy.Policy) iamUserPolicyMap := make(map[string]MappedPolicy) + iamGroupPolicyMap := make(map[string]MappedPolicy) if err := loadPolicyDocs(objAPI, iamPolicyDocsMap); err != nil { return err @@ -1501,12 +1932,19 @@ func (sys *IAMSys) refresh(objAPI ObjectLayer) error { if err := loadUsers(objAPI, true, iamUsersMap); err != nil { return err } + if err := loadGroups(objAPI, iamGroupsMap); err != nil { + return err + } - if err := loadMappedPolicies(objAPI, false, iamUserPolicyMap); err != nil { + if err := loadMappedPolicies(objAPI, false, false, iamUserPolicyMap); err != nil { return err } // load STS policy mappings into the same map - if err := loadMappedPolicies(objAPI, true, iamUserPolicyMap); err != nil { + if err := loadMappedPolicies(objAPI, true, false, iamUserPolicyMap); err != nil { + return err + } + // load policies mapped to groups + if err := loadMappedPolicies(objAPI, false, false, iamGroupPolicyMap); err != nil { return err } @@ -1519,15 +1957,60 @@ func (sys *IAMSys) refresh(objAPI ObjectLayer) error { sys.iamUsersMap = iamUsersMap sys.iamPolicyDocsMap = iamPolicyDocsMap sys.iamUserPolicyMap = iamUserPolicyMap + sys.iamGroupPolicyMap = iamGroupPolicyMap + sys.iamGroupsMap = iamGroupsMap + sys.buildUserGroupMemberships() return nil } +// buildUserGroupMemberships - builds the memberships map. IMPORTANT: +// Assumes that sys.Lock is held by caller. +func (sys *IAMSys) buildUserGroupMemberships() { + for group, gi := range sys.iamGroupsMap { + sys.updateGroupMembershipsMap(group, &gi) + } +} + +// updateGroupMembershipsMap - updates the memberships map for a +// group. IMPORTANT: Assumes sys.Lock() is held by caller. +func (sys *IAMSys) updateGroupMembershipsMap(group string, gi *GroupInfo) { + if gi == nil { + return + } + for _, member := range gi.Members { + v := sys.iamUserGroupMemberships[member] + if v == nil { + v = set.CreateStringSet(group) + } else { + v.Add(group) + } + sys.iamUserGroupMemberships[member] = v + } +} + +// removeGroupFromMembershipsMap - removes the group from every member +// in the cache. IMPORTANT: Assumes sys.Lock() is held by caller. +func (sys *IAMSys) removeGroupFromMembershipsMap(group string) { + if _, ok := sys.iamUserGroupMemberships[group]; !ok { + return + } + for member, groups := range sys.iamUserGroupMemberships { + if !groups.Contains(group) { + continue + } + groups.Remove(group) + sys.iamUserGroupMemberships[member] = groups + } +} + // NewIAMSys - creates new config system object. func NewIAMSys() *IAMSys { return &IAMSys{ - iamUsersMap: make(map[string]auth.Credentials), - iamPolicyDocsMap: make(map[string]iampolicy.Policy), - iamUserPolicyMap: make(map[string]MappedPolicy), + iamUsersMap: make(map[string]auth.Credentials), + iamPolicyDocsMap: make(map[string]iampolicy.Policy), + iamUserPolicyMap: make(map[string]MappedPolicy), + iamGroupsMap: make(map[string]GroupInfo), + iamUserGroupMemberships: make(map[string]set.StringSet), } } diff --git a/cmd/notification.go b/cmd/notification.go index 9d2c5b5a6..8907d6125 100644 --- a/cmd/notification.go +++ b/cmd/notification.go @@ -232,6 +232,19 @@ func (sys *NotificationSys) LoadUsers() []NotificationPeerErr { return ng.Wait() } +// LoadGroup - loads a specific group on all peers. +func (sys *NotificationSys) LoadGroup(group string) []NotificationPeerErr { + ng := WithNPeers(len(sys.peerClients)) + for idx, client := range sys.peerClients { + if client == nil { + continue + } + client := client + ng.Go(context.Background(), func() error { return client.LoadGroup(group) }, idx, *client.host) + } + return ng.Wait() +} + // BackgroundHealStatus - returns background heal status of all peers func (sys *NotificationSys) BackgroundHealStatus() []madmin.BgHealState { states := make([]madmin.BgHealState, len(sys.peerClients)) diff --git a/cmd/peer-rest-client.go b/cmd/peer-rest-client.go index fcfc29fc5..b381499ca 100644 --- a/cmd/peer-rest-client.go +++ b/cmd/peer-rest-client.go @@ -443,6 +443,18 @@ func (client *peerRESTClient) LoadUsers() (err error) { return nil } +// LoadGroup - send load group command to peers. +func (client *peerRESTClient) LoadGroup(group string) error { + values := make(url.Values) + values.Set(peerRESTGroup, group) + respBody, err := client.call(peerRESTMethodLoadGroup, values, nil, -1) + if err != nil { + return err + } + defer http.DrainBody(respBody) + return nil +} + // SignalService - sends signal to peer nodes. func (client *peerRESTClient) SignalService(sig serviceSignal) error { values := make(url.Values) diff --git a/cmd/peer-rest-common.go b/cmd/peer-rest-common.go index 2408d7311..2afe55c62 100644 --- a/cmd/peer-rest-common.go +++ b/cmd/peer-rest-common.go @@ -34,6 +34,7 @@ const ( peerRESTMethodLoadPolicy = "loadpolicy" peerRESTMethodDeletePolicy = "deletepolicy" peerRESTMethodLoadUsers = "loadusers" + peerRESTMethodLoadGroup = "loadgroup" peerRESTMethodStartProfiling = "startprofiling" peerRESTMethodDownloadProfilingData = "downloadprofilingdata" peerRESTMethodBucketPolicySet = "setbucketpolicy" @@ -50,6 +51,7 @@ const ( const ( peerRESTBucket = "bucket" peerRESTUser = "user" + peerRESTGroup = "group" peerRESTUserTemp = "user-temp" peerRESTPolicy = "policy" peerRESTSignal = "signal" diff --git a/cmd/peer-rest-server.go b/cmd/peer-rest-server.go index 9df1ff7ad..73fbca90b 100644 --- a/cmd/peer-rest-server.go +++ b/cmd/peer-rest-server.go @@ -260,6 +260,30 @@ func (s *peerRESTServer) LoadUsersHandler(w http.ResponseWriter, r *http.Request w.(http.Flusher).Flush() } +// LoadGroupHandler - reloads group along with members list. +func (s *peerRESTServer) LoadGroupHandler(w http.ResponseWriter, r *http.Request) { + if !s.IsValid(w, r) { + s.writeErrorResponse(w, errors.New("Invalid request")) + return + } + + objAPI := newObjectLayerFn() + if objAPI == nil { + s.writeErrorResponse(w, errServerNotInitialized) + return + } + + vars := mux.Vars(r) + group := vars[peerRESTGroup] + err := globalIAMSys.LoadGroup(objAPI, group) + if err != nil { + s.writeErrorResponse(w, err) + return + } + + w.(http.Flusher).Flush() +} + // StartProfilingHandler - Issues the start profiling command. func (s *peerRESTServer) StartProfilingHandler(w http.ResponseWriter, r *http.Request) { if !s.IsValid(w, r) { @@ -800,6 +824,7 @@ func registerPeerRESTHandlers(router *mux.Router) { subrouter.Methods(http.MethodPost).Path("/" + peerRESTMethodDeleteUser).HandlerFunc(httpTraceAll(server.LoadUserHandler)).Queries(restQueries(peerRESTUser)...) subrouter.Methods(http.MethodPost).Path("/" + peerRESTMethodLoadUser).HandlerFunc(httpTraceAll(server.LoadUserHandler)).Queries(restQueries(peerRESTUser, peerRESTUserTemp)...) subrouter.Methods(http.MethodPost).Path("/" + peerRESTMethodLoadUsers).HandlerFunc(httpTraceAll(server.LoadUsersHandler)) + subrouter.Methods(http.MethodPost).Path("/" + peerRESTMethodLoadGroup).HandlerFunc(httpTraceAll(server.LoadGroupHandler)).Queries(restQueries(peerRESTGroup)...) subrouter.Methods(http.MethodPost).Path("/" + peerRESTMethodStartProfiling).HandlerFunc(httpTraceAll(server.StartProfilingHandler)).Queries(restQueries(peerRESTProfiler)...) subrouter.Methods(http.MethodPost).Path("/" + peerRESTMethodDownloadProfilingData).HandlerFunc(httpTraceHdrs(server.DownloadProflingDataHandler)) diff --git a/cmd/sts-handlers.go b/cmd/sts-handlers.go index c6f777c79..ad997e46b 100644 --- a/cmd/sts-handlers.go +++ b/cmd/sts-handlers.go @@ -183,13 +183,18 @@ func (sts *stsAPIHandlers) AssumeRole(w http.ResponseWriter, r *http.Request) { return } - policyName, err := globalIAMSys.PolicyDBGet(user.AccessKey) + policies, err := globalIAMSys.PolicyDBGet(user.AccessKey, false) if err != nil { logger.LogIf(ctx, err) writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSInvalidParameterValue)) return } + policyName := "" + if len(policies) > 0 { + policyName = policies[0] + } + // This policy is the policy associated with the user // requesting for temporary credentials. The temporary // credentials will inherit the same policy requirements. diff --git a/cmd/typed-errors.go b/cmd/typed-errors.go index 9bdbe086f..82ae36b49 100644 --- a/cmd/typed-errors.go +++ b/cmd/typed-errors.go @@ -80,6 +80,13 @@ var errInvalidDecompressedSize = errors.New("Invalid Decompressed Size") // error returned in IAM subsystem when user doesn't exist. var errNoSuchUser = errors.New("Specified user does not exist") +// error returned in IAM subsystem when groups doesn't exist. +var errNoSuchGroup = errors.New("Specified group does not exist") + +// error returned in IAM subsystem when a non-empty group needs to be +// deleted. +var errGroupNotEmpty = errors.New("Specified group is not empty - cannot remove it") + // error returned in IAM subsystem when policy doesn't exist. var errNoSuchPolicy = errors.New("Specified canned policy does not exist") diff --git a/pkg/madmin/group-commands.go b/pkg/madmin/group-commands.go new file mode 100644 index 000000000..84377fdb8 --- /dev/null +++ b/pkg/madmin/group-commands.go @@ -0,0 +1,164 @@ +/* + * MinIO Cloud Storage, (C) 2018-2019 MinIO, Inc. + * + * Licensed 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 madmin + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/url" +) + +// GroupAddRemove is type for adding/removing members to/from a group. +type GroupAddRemove struct { + Group string `json:"group"` + Members []string `json:"members"` + IsRemove bool `json:"isRemove"` +} + +// UpdateGroupMembers - adds/removes users to/from a group. Server +// creates the group as needed. Group is removed if remove request is +// made on empty group. +func (adm *AdminClient) UpdateGroupMembers(g GroupAddRemove) error { + data, err := json.Marshal(g) + if err != nil { + return err + } + + reqData := requestData{ + relPath: "/v1/update-group-members", + content: data, + } + + // Execute PUT on /minio/admin/v1/update-group-members + resp, err := adm.executeMethod("PUT", reqData) + + defer closeResponse(resp) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return httpRespToErrorResponse(resp) + } + + return nil +} + +// GroupDesc is a type that holds group info along with the policy +// attached to it. +type GroupDesc struct { + Name string `json:"name"` + Status string `json:"status"` + Members []string `json:"members"` + Policy string `json:"policy"` +} + +// GetGroupDescription - fetches information on a group. +func (adm *AdminClient) GetGroupDescription(group string) (*GroupDesc, error) { + v := url.Values{} + v.Set("group", group) + reqData := requestData{ + relPath: "/v1/group", + queryValues: v, + } + + resp, err := adm.executeMethod("GET", reqData) + defer closeResponse(resp) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, httpRespToErrorResponse(resp) + } + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + gd := GroupDesc{} + if err = json.Unmarshal(data, &gd); err != nil { + return nil, err + } + + return &gd, nil +} + +// ListGroups - lists all groups names present on the server. +func (adm *AdminClient) ListGroups() ([]string, error) { + reqData := requestData{ + relPath: "/v1/groups", + } + + resp, err := adm.executeMethod("GET", reqData) + defer closeResponse(resp) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, httpRespToErrorResponse(resp) + } + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + groups := []string{} + if err = json.Unmarshal(data, &groups); err != nil { + return nil, err + } + + return groups, nil +} + +// GroupStatus - group status. +type GroupStatus string + +// GroupStatus values. +const ( + GroupEnabled GroupStatus = "enabled" + GroupDisabled GroupStatus = "disabled" +) + +// SetGroupStatus - sets the status of a group. +func (adm *AdminClient) SetGroupStatus(group string, status GroupStatus) error { + v := url.Values{} + v.Set("group", group) + v.Set("status", string(status)) + + reqData := requestData{ + relPath: "/v1/set-group-status", + queryValues: v, + } + + resp, err := adm.executeMethod("PUT", reqData) + defer closeResponse(resp) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return httpRespToErrorResponse(resp) + } + + return nil +}