diff --git a/api4/group.go b/api4/group.go index da17d31ce3..343db5ee7e 100644 --- a/api4/group.go +++ b/api4/group.go @@ -84,6 +84,10 @@ func (api *API) InitGroup() { api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteGroup)).Methods("DELETE") + // GET /api/v4/groups/:group_id + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/restore", + api.APISessionRequired(restoreGroup)).Methods("POST") + // POST /api/v4/groups/:group_id/members api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members", api.APISessionRequired(addGroupMembers)).Methods("POST") @@ -1125,6 +1129,55 @@ func deleteGroup(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } +func restoreGroup(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + + c.RequireGroupId() + if c.Err != nil { + return + } + + group, err := c.App.GetGroup(c.Params.GroupId, nil, nil) + if err != nil { + c.Err = err + return + } + + if group.Source != model.GroupSourceCustom { + c.Err = model.NewAppError("Api4.restoreGroup", "app.group.crud_permission", nil, "", http.StatusNotImplemented) + return + } + + if lcErr := licensedAndConfiguredForGroupBySource(c.App, model.GroupSourceCustom); lcErr != nil { + lcErr.Where = "Api4.restoreGroup" + c.Err = lcErr + return + } + + if !c.App.SessionHasPermissionToGroup(*c.AppContext.Session(), c.Params.GroupId, model.PermissionDeleteCustomGroup) { + c.SetPermissionError(model.PermissionDeleteCustomGroup) + return + } + + auditRec := c.MakeAuditRecord("restoreGroup", audit.Fail) + defer c.LogAuditRec(auditRec) + auditRec.AddMeta("group_id", c.Params.GroupId) + + _, err = c.App.RestoreGroup(c.Params.GroupId) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + + ReturnStatusOK(w) +} + func addGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { permissionErr := requireLicense(c) if permissionErr != nil { diff --git a/api4/group_test.go b/api4/group_test.go index bbe4fa33c4..468e043d4e 100644 --- a/api4/group_test.go +++ b/api4/group_test.go @@ -214,6 +214,33 @@ func TestDeleteGroup(t *testing.T) { require.NoError(t, err) CheckOKStatus(t, response) } + +func TestUndeleteGroup(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + + validGroup, appErr := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + model.NewId(), + Name: model.NewString("name" + model.NewId()), + Source: model.GroupSourceCustom, + }) + assert.Nil(t, appErr) + + _, response, err := th.Client.DeleteGroup(validGroup.Id) + require.NoError(t, err) + CheckOKStatus(t, response) + + _, response, err = th.Client.RestoreGroup(validGroup.Id, "") + require.NoError(t, err) + CheckOKStatus(t, response) + + _, response, err = th.Client.RestoreGroup(validGroup.Id, "") + require.Error(t, err) + CheckNotFoundStatus(t, response) +} + func TestPatchGroup(t *testing.T) { th := Setup(t) defer th.TearDown() diff --git a/app/app_iface.go b/app/app_iface.go index 371b9374b1..57b6f6fde1 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -968,6 +968,7 @@ type AppIface interface { ResetPermissionsSystem() *model.AppError ResetSamlAuthDataToEmail(includeDeleted bool, dryRun bool, userIDs []string) (numAffected int, appErr *model.AppError) RestoreChannel(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError) + RestoreGroup(groupID string) (*model.Group, *model.AppError) RestoreTeam(teamID string) *model.AppError RestrictUsersGetByPermissions(userID string, options *model.UserGetOptions) (*model.UserGetOptions, *model.AppError) RestrictUsersSearchByPermissions(userID string, options *model.UserSearchOptions) (*model.UserSearchOptions, *model.AppError) diff --git a/app/group.go b/app/group.go index 209c9e28c4..6db059b33f 100644 --- a/app/group.go +++ b/app/group.go @@ -217,6 +217,21 @@ func (a *App) DeleteGroup(groupID string) (*model.Group, *model.AppError) { return deletedGroup, nil } +func (a *App) RestoreGroup(groupID string) (*model.Group, *model.AppError) { + restoredGroup, err := a.Srv().Store().Group().Restore(groupID) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("RestoreGroup", "app.group.no_rows", nil, nfErr.Error(), http.StatusNotFound) + default: + return nil, model.NewAppError("RestoreGroup", "app.update_error", nil, err.Error(), http.StatusInternalServerError) + } + } + + return restoredGroup, nil +} + func (a *App) GetGroupMemberCount(groupID string, viewRestrictions *model.ViewUsersRestrictions) (int64, *model.AppError) { count, err := a.Srv().Store().Group().GetMemberCountWithRestrictions(groupID, viewRestrictions) if err != nil { diff --git a/app/group_test.go b/app/group_test.go index 3225d881b7..4419eff777 100644 --- a/app/group_test.go +++ b/app/group_test.go @@ -127,6 +127,24 @@ func TestDeleteGroup(t *testing.T) { require.Nil(t, g) } +func TestUndeleteGroup(t *testing.T) { + th := Setup(t) + defer th.TearDown() + group := th.CreateGroup() + + g, err := th.App.DeleteGroup(group.Id) + require.Nil(t, err) + require.NotNil(t, g) + + g, err = th.App.RestoreGroup(group.Id) + require.Nil(t, err) + require.NotNil(t, g) + + g, err = th.App.RestoreGroup(group.Id) + require.NotNil(t, err) + require.Nil(t, g) +} + func TestCreateOrRestoreGroupMember(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 0a2f216e89..b10458ce0d 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -13945,6 +13945,28 @@ func (a *OpenTracingAppLayer) RestoreChannel(c request.CTX, channel *model.Chann return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) RestoreGroup(groupID string) (*model.Group, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RestoreGroup") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.RestoreGroup(groupID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) RestoreTeam(teamID string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RestoreTeam") diff --git a/model/client4.go b/model/client4.go index b569a8fa86..6ab4ed4952 100644 --- a/model/client4.go +++ b/model/client4.go @@ -7433,6 +7433,19 @@ func (c *Client4) DeleteGroup(groupID string) (*Group, *Response, error) { return &p, BuildResponse(r), nil } +func (c *Client4) RestoreGroup(groupID string, etag string) (*Group, *Response, error) { + r, err := c.DoAPIPost(c.groupRoute(groupID)+"/restore", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var p Group + if jsonErr := json.NewDecoder(r.Body).Decode(&p); jsonErr != nil { + return nil, nil, NewAppError("DeleteGroup", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return &p, BuildResponse(r), nil +} + func (c *Client4) PatchGroup(groupID string, patch *GroupPatch) (*Group, *Response, error) { payload, err := json.Marshal(patch) if err != nil { diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index b500b0cb53..513749fd4b 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -4512,6 +4512,24 @@ func (s *OpenTracingLayerGroupStore) PermittedSyncableAdmins(syncableID string, return result, err } +func (s *OpenTracingLayerGroupStore) Restore(groupID string) (*model.Group, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "GroupStore.Restore") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.GroupStore.Restore(groupID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerGroupStore) TeamMembersMinusGroupMembers(teamID string, groupIDs []string, page int, perPage int) ([]*model.UserWithGroups, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "GroupStore.TeamMembersMinusGroupMembers") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 3e6fcb0b31..fdf6b2c9e9 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -5097,6 +5097,27 @@ func (s *RetryLayerGroupStore) PermittedSyncableAdmins(syncableID string, syncab } +func (s *RetryLayerGroupStore) Restore(groupID string) (*model.Group, error) { + + tries := 0 + for { + result, err := s.GroupStore.Restore(groupID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerGroupStore) TeamMembersMinusGroupMembers(teamID string, groupIDs []string, page int, perPage int) ([]*model.UserWithGroups, error) { tries := 0 diff --git a/store/sqlstore/group_store.go b/store/sqlstore/group_store.go index c3341dc9d6..40866d2a7a 100644 --- a/store/sqlstore/group_store.go +++ b/store/sqlstore/group_store.go @@ -360,6 +360,25 @@ func (s *SqlGroupStore) Delete(groupID string) (*model.Group, error) { return &group, nil } +func (s *SqlGroupStore) Restore(groupID string) (*model.Group, error) { + var group model.Group + if err := s.GetReplicaX().Get(&group, "SELECT * from UserGroups WHERE Id = ? AND DeleteAt != 0", groupID); err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Group", groupID) + } + return nil, errors.Wrapf(err, "failed to get Group with id=%s", groupID) + } + + time := model.GetMillis() + if _, err := s.GetMasterX().Exec(`UPDATE UserGroups + SET DeleteAt=0, UpdateAt=? + WHERE Id=? AND DeleteAt!=0`, time, groupID); err != nil { + return nil, errors.Wrapf(err, "failed to update Group with id=%s", groupID) + } + + return &group, nil +} + func (s *SqlGroupStore) GetMember(groupID, userID string) (*model.GroupMember, error) { query, args, err := s.getQueryBuilder(). Select("*"). diff --git a/store/store.go b/store/store.go index 77092a2aed..055b0dfeea 100644 --- a/store/store.go +++ b/store/store.go @@ -832,6 +832,7 @@ type GroupStore interface { GetByUser(userID string) ([]*model.Group, error) Update(group *model.Group) (*model.Group, error) Delete(groupID string) (*model.Group, error) + Restore(groupID string) (*model.Group, error) GetMemberUsers(groupID string) ([]*model.User, error) GetMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, error) diff --git a/store/storetest/group_store.go b/store/storetest/group_store.go index dbd292f1d3..02d788765d 100644 --- a/store/storetest/group_store.go +++ b/store/storetest/group_store.go @@ -32,6 +32,7 @@ func TestGroupStore(t *testing.T, ss store.Store) { t.Run("GetByUser", func(t *testing.T) { testGroupStoreGetByUser(t, ss) }) t.Run("Update", func(t *testing.T) { testGroupStoreUpdate(t, ss) }) t.Run("Delete", func(t *testing.T) { testGroupStoreDelete(t, ss) }) + t.Run("Restore", func(t *testing.T) { testGroupStoreRestore(t, ss) }) t.Run("GetMemberUsers", func(t *testing.T) { testGroupGetMemberUsers(t, ss) }) t.Run("GetMemberUsersPage", func(t *testing.T) { testGroupGetMemberUsersPage(t, ss) }) @@ -741,6 +742,59 @@ func testGroupStoreDelete(t *testing.T, ss store.Store) { require.True(t, errors.As(err, &nfErr)) } +func testGroupStoreRestore(t *testing.T, ss store.Store) { + // Save a group + g1 := &model.Group{ + Name: model.NewString(model.NewId()), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewString(model.NewId()), + } + + d1, err := ss.Group().Create(g1) + require.NoError(t, err) + require.Len(t, d1.Id, 26) + + // Check the group is retrievable + _, err = ss.Group().Get(d1.Id) + require.NoError(t, err) + + // Delete the group + _, err = ss.Group().Delete(d1.Id) + require.NoError(t, err) + + // Get the before count + d7, err := ss.Group().GetAllBySource(model.GroupSourceLdap) + require.NoError(t, err) + beforeCount := len(d7) + + // restore the group + _, err = ss.Group().Restore(d1.Id) + require.NoError(t, err) + + // Check the group is restored + d4, err := ss.Group().Get(d1.Id) + require.NoError(t, err) + require.Zero(t, d4.DeleteAt) + + // Check the after count + d5, err := ss.Group().GetAllBySource(model.GroupSourceLdap) + require.NoError(t, err) + afterCount := len(d5) + require.Condition(t, func() bool { return beforeCount == afterCount-1 }) + + // Try and restore a nonexistent group + _, err = ss.Group().Delete(model.NewId()) + require.Error(t, err) + var nfErr *store.ErrNotFound + require.True(t, errors.As(err, &nfErr)) + + // Cannot restore again + _, err = ss.Group().Restore(d1.Id) + require.True(t, errors.As(err, &nfErr)) +} + func testGroupGetMemberUsers(t *testing.T, ss store.Store) { // Save a group g1 := &model.Group{ diff --git a/store/storetest/mocks/GroupStore.go b/store/storetest/mocks/GroupStore.go index 80d6fdd492..7f54c5487b 100644 --- a/store/storetest/mocks/GroupStore.go +++ b/store/storetest/mocks/GroupStore.go @@ -1012,6 +1012,29 @@ func (_m *GroupStore) PermittedSyncableAdmins(syncableID string, syncableType mo return r0, r1 } +// Restore provides a mock function with given fields: groupID +func (_m *GroupStore) Restore(groupID string) (*model.Group, error) { + ret := _m.Called(groupID) + + var r0 *model.Group + if rf, ok := ret.Get(0).(func(string) *model.Group); ok { + r0 = rf(groupID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Group) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(groupID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // TeamMembersMinusGroupMembers provides a mock function with given fields: teamID, groupIDs, page, perPage func (_m *GroupStore) TeamMembersMinusGroupMembers(teamID string, groupIDs []string, page int, perPage int) ([]*model.UserWithGroups, error) { ret := _m.Called(teamID, groupIDs, page, perPage) diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 16f6044d24..9e8bb2fade 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -4101,6 +4101,22 @@ func (s *TimerLayerGroupStore) PermittedSyncableAdmins(syncableID string, syncab return result, err } +func (s *TimerLayerGroupStore) Restore(groupID string) (*model.Group, error) { + start := time.Now() + + result, err := s.GroupStore.Restore(groupID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("GroupStore.Restore", success, elapsed) + } + return result, err +} + func (s *TimerLayerGroupStore) TeamMembersMinusGroupMembers(teamID string, groupIDs []string, page int, perPage int) ([]*model.UserWithGroups, error) { start := time.Now()