From 4d6602aff03ed005c59d564a6b2af5bc2adc6bde Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Tue, 26 Mar 2024 08:43:25 -0600 Subject: [PATCH] [MM-57295] Bulk export: add roles and permission schemes (#26523) * Bulk export: add roles and permission schemes * Update mmctl docs * Fix log * Update mmctl tests * Update mmctl unit tests * Refactor to avoid extra calls * Update translations * Add test case * Fix test * Fix test --- server/channels/app/export.go | 106 +++++ server/channels/app/export_converters.go | 43 ++ server/channels/app/export_test.go | 409 ++++++++++++++++++ server/channels/app/import.go | 5 + server/channels/app/import_functions.go | 40 +- server/channels/app/import_functions_test.go | 15 +- server/channels/app/imports/import_types.go | 10 +- server/channels/jobs/export_process/worker.go | 5 + server/cmd/mmctl/commands/export.go | 6 + server/cmd/mmctl/commands/export_e2e_test.go | 17 +- server/cmd/mmctl/commands/export_test.go | 34 +- server/cmd/mmctl/commands/import.go | 3 + .../cmd/mmctl/commands/importer/validate.go | 40 ++ server/cmd/mmctl/docs/mmctl_export_create.rst | 1 + server/i18n/en.json | 12 + server/public/model/bulk_export.go | 1 + 16 files changed, 712 insertions(+), 35 deletions(-) diff --git a/server/channels/app/export.go b/server/channels/app/export.go index dc00aee041..db66427eed 100644 --- a/server/channels/app/export.go +++ b/server/channels/app/export.go @@ -90,6 +90,12 @@ func (a *App) BulkExport(ctx request.CTX, writer io.Writer, outPath string, job return err } + if opts.IncludeRolesAndSchemes { + if err := a.exportRolesAndSchemes(ctx, job, writer); err != nil { + return err + } + } + ctx.Logger().Info("Bulk export: exporting teams") teamNames, err := a.exportAllTeams(ctx, job, writer) if err != nil { @@ -194,6 +200,106 @@ func (a *App) exportVersion(writer io.Writer) *model.AppError { return a.exportWriteLine(writer, versionLine) } +func (a *App) exportRolesAndSchemes(ctx request.CTX, job *model.Job, writer io.Writer) *model.AppError { + // We export schemes first since they'll already include their attached roles + // which we map to avoid exporting them twice later in exportRoles. + schemeRolesMap := make(map[string]bool) + + roles, appErr := a.Srv().Store().Role().GetAll() + if appErr != nil { + return model.NewAppError("exportRolesAndSchemes", "app.role.get_all.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) + } + + ctx.Logger().Info("Bulk export: exporting team schemes") + if err := a.exportSchemes(ctx, job, writer, model.SchemeScopeTeam, schemeRolesMap, roles); err != nil { + return err + } + + ctx.Logger().Info("Bulk export: exporting channel schemes") + if err := a.exportSchemes(ctx, job, writer, model.SchemeScopeChannel, schemeRolesMap, roles); err != nil { + return err + } + + ctx.Logger().Info("Bulk export: exporting roles") + if err := a.exportRoles(ctx, job, writer, schemeRolesMap, roles); err != nil { + return err + } + + return nil +} + +func (a *App) exportRoles(ctx request.CTX, job *model.Job, writer io.Writer, schemeRoles map[string]bool, allRoles []*model.Role) *model.AppError { + var cnt int + for _, role := range allRoles { + // We skip any roles that will be included as part of custom schemes. + if !schemeRoles[role.Name] { + if err := a.exportWriteLine(writer, ImportLineFromRole(role)); err != nil { + return err + } + cnt++ + } + } + + updateJobProgress(ctx.Logger(), a.Srv().Store(), job, "roles_exported", cnt) + + return nil +} + +func (a *App) exportSchemes(ctx request.CTX, job *model.Job, writer io.Writer, scope string, schemeRolesMap map[string]bool, allRoles []*model.Role) *model.AppError { + rolesMap := make(map[string]*model.Role, len(allRoles)) + for _, role := range allRoles { + rolesMap[role.Name] = role + } + + var cnt int + pageSize := 100 + + for { + schemes, err := a.Srv().Store().Scheme().GetAllPage(scope, cnt, pageSize) + if err != nil { + return model.NewAppError("exportSchemes", "app.scheme.get_all_page.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + for _, scheme := range schemes { + if ok := scheme.IsValid(); !ok { + return model.NewAppError("exportSchemes", "model.scheme.is_valid.app_error", nil, "", http.StatusInternalServerError) + } + + if scheme.Scope == model.SchemeScopeTeam { + schemeRolesMap[scheme.DefaultTeamAdminRole] = true + schemeRolesMap[scheme.DefaultTeamUserRole] = true + schemeRolesMap[scheme.DefaultTeamGuestRole] = true + + // Playbooks + // At the moment this is only needed to avoid exporting and + // importing spurious roles. + schemeRolesMap[scheme.DefaultPlaybookAdminRole] = true + schemeRolesMap[scheme.DefaultPlaybookMemberRole] = true + schemeRolesMap[scheme.DefaultRunAdminRole] = true + schemeRolesMap[scheme.DefaultRunMemberRole] = true + } + + if scheme.Scope == model.SchemeScopeTeam || scheme.Scope == model.SchemeScopeChannel { + schemeRolesMap[scheme.DefaultChannelAdminRole] = true + schemeRolesMap[scheme.DefaultChannelUserRole] = true + schemeRolesMap[scheme.DefaultChannelGuestRole] = true + } + + if err := a.exportWriteLine(writer, ImportLineFromScheme(scheme, rolesMap)); err != nil { + return err + } + } + + cnt += len(schemes) + + updateJobProgress(ctx.Logger(), a.Srv().Store(), job, fmt.Sprintf("%s_schemes_exported", scope), cnt) + + if len(schemes) < pageSize { + return nil + } + } +} + func (a *App) exportAllTeams(ctx request.CTX, job *model.Job, writer io.Writer) (map[string]bool, *model.AppError) { afterId := strings.Repeat("0", 26) teamNames := make(map[string]bool) diff --git a/server/channels/app/export_converters.go b/server/channels/app/export_converters.go index f270158ff8..9507679735 100644 --- a/server/channels/app/export_converters.go +++ b/server/channels/app/export_converters.go @@ -226,3 +226,46 @@ func ImportLineFromEmoji(emoji *model.Emoji, filePath string) *imports.LineImpor }, } } + +func ImportRoleDataFromRole(role *model.Role) *imports.RoleImportData { + return &imports.RoleImportData{ + Name: &role.Name, + DisplayName: &role.DisplayName, + Description: &role.Description, + Permissions: &role.Permissions, + SchemeManaged: &role.SchemeManaged, + } +} + +func ImportLineFromRole(role *model.Role) *imports.LineImportData { + return &imports.LineImportData{ + Type: "role", + Role: ImportRoleDataFromRole(role), + } +} + +func ImportLineFromScheme(scheme *model.Scheme, rolesMap map[string]*model.Role) *imports.LineImportData { + data := &imports.SchemeImportData{ + Name: &scheme.Name, + DisplayName: &scheme.DisplayName, + Description: &scheme.Description, + Scope: &scheme.Scope, + } + + if scheme.Scope == model.SchemeScopeTeam { + data.DefaultTeamAdminRole = ImportRoleDataFromRole(rolesMap[scheme.DefaultTeamAdminRole]) + data.DefaultTeamUserRole = ImportRoleDataFromRole(rolesMap[scheme.DefaultTeamUserRole]) + data.DefaultTeamGuestRole = ImportRoleDataFromRole(rolesMap[scheme.DefaultTeamGuestRole]) + } + + if scheme.Scope == model.SchemeScopeTeam || scheme.Scope == model.SchemeScopeChannel { + data.DefaultChannelAdminRole = ImportRoleDataFromRole(rolesMap[scheme.DefaultChannelAdminRole]) + data.DefaultChannelUserRole = ImportRoleDataFromRole(rolesMap[scheme.DefaultChannelUserRole]) + data.DefaultChannelGuestRole = ImportRoleDataFromRole(rolesMap[scheme.DefaultChannelGuestRole]) + } + + return &imports.LineImportData{ + Type: "scheme", + Scheme: data, + } +} diff --git a/server/channels/app/export_test.go b/server/channels/app/export_test.go index 6e3a4fb513..5b0f7e6c7a 100644 --- a/server/channels/app/export_test.go +++ b/server/channels/app/export_test.go @@ -804,3 +804,412 @@ func TestExportArchivedChannels(t *testing.T) { } require.True(t, found, "archived channel not found after import") } + +func TestExportRoles(t *testing.T) { + t.Run("defaults", func(t *testing.T) { + th1 := Setup(t).InitBasic() + defer th1.TearDown() + + var b bytes.Buffer + appErr := th1.App.BulkExport(th1.Context, &b, "", nil, model.BulkExportOpts{}) + require.Nil(t, appErr) + + exportedRoles, appErr := th1.App.GetAllRoles() + assert.Nil(t, appErr) + assert.NotEmpty(t, exportedRoles) + + th2 := Setup(t) + defer th2.TearDown() + appErr, i := th2.App.BulkImport(th2.Context, &b, nil, false, 1) + assert.Nil(t, appErr) + assert.Equal(t, 0, i) + + importedRoles, appErr := th2.App.GetAllRoles() + assert.Nil(t, appErr) + assert.NotEmpty(t, importedRoles) + + require.Equal(t, len(exportedRoles), len(importedRoles)) + }) + + t.Run("modified roles", func(t *testing.T) { + th1 := Setup(t).InitBasic() + defer th1.TearDown() + + exportedRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), model.TeamUserRoleId) + require.Nil(t, appErr) + + exportedRole.Permissions = exportedRole.Permissions[1:] + + _, appErr = th1.App.UpdateRole(exportedRole) + require.Nil(t, appErr) + + var b bytes.Buffer + appErr = th1.App.BulkExport(th1.Context, &b, "", nil, model.BulkExportOpts{ + IncludeRolesAndSchemes: true, + }) + require.Nil(t, appErr) + + th2 := Setup(t) + defer th2.TearDown() + appErr, i := th2.App.BulkImport(th2.Context, &b, nil, false, 1) + require.Nil(t, appErr) + require.Equal(t, 0, i) + + importedRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), model.TeamUserRoleId) + require.Nil(t, appErr) + + require.Equal(t, exportedRole.DisplayName, importedRole.DisplayName) + require.Equal(t, exportedRole.Description, importedRole.Description) + require.Equal(t, exportedRole.SchemeManaged, importedRole.SchemeManaged) + require.Equal(t, exportedRole.BuiltIn, importedRole.BuiltIn) + require.ElementsMatch(t, exportedRole.Permissions, importedRole.Permissions) + }) + + t.Run("custom roles", func(t *testing.T) { + th1 := Setup(t).InitBasic() + defer th1.TearDown() + + exportedRoles, appErr := th1.App.GetAllRoles() + require.Nil(t, appErr) + require.NotEmpty(t, exportedRoles) + + customRole, appErr := th1.App.CreateRole(&model.Role{ + Name: "custom_role", + DisplayName: "custom_role", + Permissions: exportedRoles[0].Permissions, + }) + require.Nil(t, appErr) + + var b bytes.Buffer + appErr = th1.App.BulkExport(th1.Context, &b, "", nil, model.BulkExportOpts{ + IncludeRolesAndSchemes: true, + }) + require.Nil(t, appErr) + + th2 := Setup(t) + defer th2.TearDown() + appErr, i := th2.App.BulkImport(th2.Context, &b, nil, false, 1) + require.Nil(t, appErr) + require.Equal(t, 0, i) + + importedCustomRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), customRole.Name) + require.Nil(t, appErr) + + require.Equal(t, customRole.DisplayName, importedCustomRole.DisplayName) + require.Equal(t, customRole.Description, importedCustomRole.Description) + require.Equal(t, customRole.SchemeManaged, importedCustomRole.SchemeManaged) + require.Equal(t, customRole.BuiltIn, importedCustomRole.BuiltIn) + require.ElementsMatch(t, customRole.Permissions, importedCustomRole.Permissions) + }) +} + +func TestExportSchemes(t *testing.T) { + t.Run("no schemes", func(t *testing.T) { + th1 := Setup(t).InitBasic() + defer th1.TearDown() + + // Need to set this or working with schemes won't work until the job is + // completed which is unnecessary for the purpose of this test. + err := th1.App.Srv().Store().System().Save(&model.System{Name: model.MigrationKeyAdvancedPermissionsPhase2, Value: "true"}) + require.NoError(t, err) + + schemes, err := th1.App.Srv().Store().Scheme().GetAllPage(model.SchemeScopeChannel, 0, 1) + require.NoError(t, err) + require.Empty(t, schemes) + + schemes, err = th1.App.Srv().Store().Scheme().GetAllPage(model.SchemeScopeTeam, 0, 1) + require.NoError(t, err) + require.Empty(t, schemes) + + var b bytes.Buffer + appErr := th1.App.BulkExport(th1.Context, &b, "", nil, model.BulkExportOpts{ + IncludeRolesAndSchemes: true, + }) + require.Nil(t, appErr) + + // The following causes the original store to be wiped so from here on we are targeting the + // second instance where the import will be loaded. + th2 := Setup(t) + defer th2.TearDown() + err = th2.App.Srv().Store().System().Save(&model.System{Name: model.MigrationKeyAdvancedPermissionsPhase2, Value: "true"}) + require.NoError(t, err) + + appErr, i := th2.App.BulkImport(th2.Context, &b, nil, false, 1) + require.Nil(t, appErr) + require.Equal(t, 0, i) + + schemes, err = th2.App.Srv().Store().Scheme().GetAllPage(model.SchemeScopeChannel, 0, 1) + require.NoError(t, err) + require.Empty(t, schemes) + + schemes, err = th2.App.Srv().Store().Scheme().GetAllPage(model.SchemeScopeTeam, 0, 1) + require.NoError(t, err) + require.Empty(t, schemes) + }) + + t.Run("skip export", func(t *testing.T) { + th1 := Setup(t).InitBasic() + defer th1.TearDown() + + // Need to set this or working with schemes won't work until the job is + // completed which is unnecessary for the purpose of this test. + err := th1.App.Srv().Store().System().Save(&model.System{Name: model.MigrationKeyAdvancedPermissionsPhase2, Value: "true"}) + require.NoError(t, err) + + customScheme, appErr := th1.App.CreateScheme(&model.Scheme{ + Name: "custom_scheme", + DisplayName: "Custom Scheme", + Scope: model.SchemeScopeChannel, + }) + require.Nil(t, appErr) + + var b bytes.Buffer + appErr = th1.App.BulkExport(th1.Context, &b, "", nil, model.BulkExportOpts{}) + require.Nil(t, appErr) + + // The following causes the original store to be wiped so from here on we are targeting the + // second instance where the import will be loaded. + th2 := Setup(t) + defer th2.TearDown() + err = th2.App.Srv().Store().System().Save(&model.System{Name: model.MigrationKeyAdvancedPermissionsPhase2, Value: "true"}) + require.NoError(t, err) + + appErr, i := th2.App.BulkImport(th2.Context, &b, nil, false, 1) + require.Nil(t, appErr) + require.Equal(t, 0, i) + + // Verify the scheme doesn't exist which is the expectation as it wasn't exported. + _, appErr = th2.App.GetScheme(customScheme.Name) + require.NotNil(t, appErr) + }) + + t.Run("export channel scheme", func(t *testing.T) { + th1 := Setup(t).InitBasic() + defer th1.TearDown() + + // Need to set this or working with schemes won't work until the job is + // completed which is unnecessary for the purpose of this test. + err := th1.App.Srv().Store().System().Save(&model.System{Name: model.MigrationKeyAdvancedPermissionsPhase2, Value: "true"}) + require.NoError(t, err) + + builtInRoles := 23 + defaultChannelSchemeRoles := 3 + + // Verify the roles count is expected prior to scheme creation. + roles, appErr := th1.App.GetAllRoles() + require.Nil(t, appErr) + require.Len(t, roles, builtInRoles) + + customScheme, appErr := th1.App.CreateScheme(&model.Scheme{ + Name: "custom_channel_scheme", + DisplayName: "Custom Channel Scheme", + Scope: model.SchemeScopeChannel, + }) + require.Nil(t, appErr) + + // Verify the roles count is expected after scheme creation. + roles, appErr = th1.App.GetAllRoles() + require.Nil(t, appErr) + require.Len(t, roles, builtInRoles+defaultChannelSchemeRoles) + + // Fetch the scheme roles for later comparison + customChannelAdminRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), customScheme.DefaultChannelAdminRole) + require.Nil(t, appErr) + customChannelUserRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), customScheme.DefaultChannelUserRole) + require.Nil(t, appErr) + customChannelGuestRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), customScheme.DefaultChannelGuestRole) + require.Nil(t, appErr) + + var b bytes.Buffer + appErr = th1.App.BulkExport(th1.Context, &b, "", nil, model.BulkExportOpts{ + IncludeRolesAndSchemes: true, + }) + require.Nil(t, appErr) + + // The following causes the original store to be wiped so from here on we are targeting the + // second instance where the import will be loaded. + th2 := Setup(t) + defer th2.TearDown() + err = th2.App.Srv().Store().System().Save(&model.System{Name: model.MigrationKeyAdvancedPermissionsPhase2, Value: "true"}) + require.NoError(t, err) + + // Verify roles count before importing is as expected. + roles, appErr = th2.App.GetAllRoles() + require.Nil(t, appErr) + require.Len(t, roles, builtInRoles) + + appErr, i := th2.App.BulkImport(th2.Context, &b, nil, false, 1) + require.Nil(t, appErr) + require.Equal(t, 0, i) + + // Verify roles count after importing is as expected. + roles, appErr = th2.App.GetAllRoles() + require.Nil(t, appErr) + require.Len(t, roles, builtInRoles+defaultChannelSchemeRoles) + + // Verify schemes match + importedScheme, appErr := th2.App.GetSchemeByName(customScheme.Name) + require.Nil(t, appErr) + require.Equal(t, customScheme.Name, importedScheme.Name) + require.Equal(t, customScheme.DisplayName, importedScheme.DisplayName) + require.Equal(t, customScheme.Description, importedScheme.Description) + require.Equal(t, customScheme.Scope, importedScheme.Scope) + + // Verify scheme roles match + importedChannelAdminRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), importedScheme.DefaultChannelAdminRole) + require.Nil(t, appErr) + require.Equal(t, customChannelAdminRole.DisplayName, importedChannelAdminRole.DisplayName) + require.Equal(t, customChannelAdminRole.Description, importedChannelAdminRole.Description) + require.Equal(t, customChannelAdminRole.Permissions, importedChannelAdminRole.Permissions) + require.Equal(t, customChannelAdminRole.SchemeManaged, importedChannelAdminRole.SchemeManaged) + require.Equal(t, customChannelAdminRole.BuiltIn, importedChannelAdminRole.BuiltIn) + + importedChannelUserRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), importedScheme.DefaultChannelUserRole) + require.Nil(t, appErr) + require.Equal(t, customChannelUserRole.DisplayName, importedChannelUserRole.DisplayName) + require.Equal(t, customChannelUserRole.Description, importedChannelUserRole.Description) + require.Equal(t, customChannelUserRole.Permissions, importedChannelUserRole.Permissions) + require.Equal(t, customChannelUserRole.SchemeManaged, importedChannelUserRole.SchemeManaged) + require.Equal(t, customChannelUserRole.BuiltIn, importedChannelUserRole.BuiltIn) + + importedChannelGuestRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), importedScheme.DefaultChannelGuestRole) + require.Nil(t, appErr) + require.Equal(t, customChannelGuestRole.DisplayName, importedChannelGuestRole.DisplayName) + require.Equal(t, customChannelGuestRole.Description, importedChannelGuestRole.Description) + require.Equal(t, customChannelGuestRole.Permissions, importedChannelGuestRole.Permissions) + require.Equal(t, customChannelGuestRole.SchemeManaged, importedChannelGuestRole.SchemeManaged) + require.Equal(t, customChannelGuestRole.BuiltIn, importedChannelGuestRole.BuiltIn) + }) + + t.Run("export team scheme", func(t *testing.T) { + th1 := Setup(t).InitBasic() + defer th1.TearDown() + + // Need to set this or working with schemes won't work until the job is + // completed which is unnecessary for the purpose of this test. + err := th1.App.Srv().Store().System().Save(&model.System{Name: model.MigrationKeyAdvancedPermissionsPhase2, Value: "true"}) + require.NoError(t, err) + + builtInRoles := 23 + defaultTeamSchemeRoles := 10 + + // Verify the roles count is expected prior to scheme creation. + roles, appErr := th1.App.GetAllRoles() + require.Nil(t, appErr) + require.Len(t, roles, builtInRoles) + + customScheme, appErr := th1.App.CreateScheme(&model.Scheme{ + Name: "custom_team_scheme", + DisplayName: "Custom Team Scheme", + Scope: model.SchemeScopeTeam, + }) + require.Nil(t, appErr) + + // Verify the roles count is expected after scheme creation. + roles, appErr = th1.App.GetAllRoles() + require.Nil(t, appErr) + require.Len(t, roles, builtInRoles+defaultTeamSchemeRoles) + + customChannelAdminRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), customScheme.DefaultChannelAdminRole) + require.Nil(t, appErr) + + customChannelUserRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), customScheme.DefaultChannelUserRole) + require.Nil(t, appErr) + + customChannelGuestRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), customScheme.DefaultChannelGuestRole) + require.Nil(t, appErr) + + customTeamAdminRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), customScheme.DefaultTeamAdminRole) + require.Nil(t, appErr) + + customTeamUserRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), customScheme.DefaultTeamUserRole) + require.Nil(t, appErr) + + customTeamGuestRole, appErr := th1.App.GetRoleByName(th1.Context.Context(), customScheme.DefaultTeamGuestRole) + require.Nil(t, appErr) + + var b bytes.Buffer + appErr = th1.App.BulkExport(th1.Context, &b, "", nil, model.BulkExportOpts{ + IncludeRolesAndSchemes: true, + }) + require.Nil(t, appErr) + + // The following causes the original store to be wiped so from here on we are targeting the + // second instance where the import will be loaded. + th2 := Setup(t) + defer th2.TearDown() + err = th2.App.Srv().Store().System().Save(&model.System{Name: model.MigrationKeyAdvancedPermissionsPhase2, Value: "true"}) + require.NoError(t, err) + + // Verify roles count before importing is as expected. + roles, appErr = th2.App.GetAllRoles() + require.Nil(t, appErr) + require.Len(t, roles, builtInRoles) + + appErr, i := th2.App.BulkImport(th2.Context, &b, nil, false, 1) + require.Nil(t, appErr) + require.Equal(t, 0, i) + + // Verify roles count after importing is as expected. + roles, appErr = th2.App.GetAllRoles() + require.Nil(t, appErr) + require.Len(t, roles, builtInRoles+defaultTeamSchemeRoles) + + // Verify schemes match + importedScheme, appErr := th2.App.GetSchemeByName(customScheme.Name) + require.Nil(t, appErr) + require.Equal(t, customScheme.Name, importedScheme.Name) + require.Equal(t, customScheme.DisplayName, importedScheme.DisplayName) + require.Equal(t, customScheme.Description, importedScheme.Description) + require.Equal(t, customScheme.Scope, importedScheme.Scope) + + // Verify scheme roles match + importedChannelAdminRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), importedScheme.DefaultChannelAdminRole) + require.Nil(t, appErr) + require.Equal(t, customChannelAdminRole.DisplayName, importedChannelAdminRole.DisplayName) + require.Equal(t, customChannelAdminRole.Description, importedChannelAdminRole.Description) + require.Equal(t, customChannelAdminRole.Permissions, importedChannelAdminRole.Permissions) + require.Equal(t, customChannelAdminRole.SchemeManaged, importedChannelAdminRole.SchemeManaged) + require.Equal(t, customChannelAdminRole.BuiltIn, importedChannelAdminRole.BuiltIn) + + importedChannelUserRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), importedScheme.DefaultChannelUserRole) + require.Nil(t, appErr) + require.Equal(t, customChannelUserRole.DisplayName, importedChannelUserRole.DisplayName) + require.Equal(t, customChannelUserRole.Description, importedChannelUserRole.Description) + require.Equal(t, customChannelUserRole.Permissions, importedChannelUserRole.Permissions) + require.Equal(t, customChannelUserRole.SchemeManaged, importedChannelUserRole.SchemeManaged) + require.Equal(t, customChannelUserRole.BuiltIn, importedChannelUserRole.BuiltIn) + + importedChannelGuestRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), importedScheme.DefaultChannelGuestRole) + require.Nil(t, appErr) + require.Equal(t, customChannelGuestRole.DisplayName, importedChannelGuestRole.DisplayName) + require.Equal(t, customChannelGuestRole.Description, importedChannelGuestRole.Description) + require.Equal(t, customChannelGuestRole.Permissions, importedChannelGuestRole.Permissions) + require.Equal(t, customChannelGuestRole.SchemeManaged, importedChannelGuestRole.SchemeManaged) + require.Equal(t, customChannelGuestRole.BuiltIn, importedChannelGuestRole.BuiltIn) + + importedTeamAdminRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), importedScheme.DefaultTeamAdminRole) + require.Nil(t, appErr) + require.Equal(t, customTeamAdminRole.DisplayName, importedTeamAdminRole.DisplayName) + require.Equal(t, customTeamAdminRole.Description, importedTeamAdminRole.Description) + require.Equal(t, customTeamAdminRole.Permissions, importedTeamAdminRole.Permissions) + require.Equal(t, customTeamAdminRole.SchemeManaged, importedTeamAdminRole.SchemeManaged) + require.Equal(t, customTeamAdminRole.BuiltIn, importedTeamAdminRole.BuiltIn) + + importedTeamUserRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), importedScheme.DefaultTeamUserRole) + require.Nil(t, appErr) + require.Equal(t, customTeamUserRole.DisplayName, importedTeamUserRole.DisplayName) + require.Equal(t, customTeamUserRole.Description, importedTeamUserRole.Description) + require.Equal(t, customTeamUserRole.Permissions, importedTeamUserRole.Permissions) + require.Equal(t, customTeamUserRole.SchemeManaged, importedTeamUserRole.SchemeManaged) + require.Equal(t, customTeamUserRole.BuiltIn, importedTeamUserRole.BuiltIn) + + importedTeamGuestRole, appErr := th2.App.GetRoleByName(th2.Context.Context(), importedScheme.DefaultTeamGuestRole) + require.Nil(t, appErr) + require.Equal(t, customTeamGuestRole.DisplayName, importedTeamGuestRole.DisplayName) + require.Equal(t, customTeamGuestRole.Description, importedTeamGuestRole.Description) + require.Equal(t, customTeamGuestRole.Permissions, importedTeamGuestRole.Permissions) + require.Equal(t, customTeamGuestRole.SchemeManaged, importedTeamGuestRole.SchemeManaged) + require.Equal(t, customTeamGuestRole.BuiltIn, importedTeamGuestRole.BuiltIn) + }) +} diff --git a/server/channels/app/import.go b/server/channels/app/import.go index 223e6c34e4..ef4dd98ff7 100644 --- a/server/channels/app/import.go +++ b/server/channels/app/import.go @@ -314,6 +314,11 @@ func processImportDataFileVersionLine(line imports.LineImportData) (int, *model. func (a *App) importLine(c request.CTX, line imports.LineImportData, dryRun bool) *model.AppError { switch { + case line.Type == "role": + if line.Role == nil { + return model.NewAppError("BulkImport", "app.import.import_line.null_role.error", nil, "", http.StatusBadRequest) + } + return a.importRole(c, line.Role, dryRun) case line.Type == "scheme": if line.Scheme == nil { return model.NewAppError("BulkImport", "app.import.import_line.null_scheme.error", nil, "", http.StatusBadRequest) diff --git a/server/channels/app/import_functions.go b/server/channels/app/import_functions.go index b43bb9a4c8..039a0ee027 100644 --- a/server/channels/app/import_functions.go +++ b/server/channels/app/import_functions.go @@ -31,9 +31,9 @@ import ( func (a *App) importScheme(rctx request.CTX, data *imports.SchemeImportData, dryRun bool) *model.AppError { var fields []mlog.Field if data != nil && data.Name != nil { - fields = append(fields, mlog.String("schema_name", *data.Name)) + fields = append(fields, mlog.String("scheme_name", *data.Name)) } - rctx.Logger().Info("Validating schema", fields...) + rctx.Logger().Info("Validating scheme", fields...) if err := imports.ValidateSchemeImportData(data); err != nil { return err @@ -44,7 +44,7 @@ func (a *App) importScheme(rctx request.CTX, data *imports.SchemeImportData, dry return nil } - rctx.Logger().Info("Importing schema", fields...) + rctx.Logger().Info("Importing scheme", fields...) scheme, err := a.GetSchemeByName(*data.Name) if err != nil { @@ -73,44 +73,46 @@ func (a *App) importScheme(rctx request.CTX, data *imports.SchemeImportData, dry if scheme.Scope == model.SchemeScopeTeam { data.DefaultTeamAdminRole.Name = &scheme.DefaultTeamAdminRole - if err := a.importRole(rctx, data.DefaultTeamAdminRole, dryRun, true); err != nil { + if err := a.importRole(rctx, data.DefaultTeamAdminRole, dryRun); err != nil { return err } data.DefaultTeamUserRole.Name = &scheme.DefaultTeamUserRole - if err := a.importRole(rctx, data.DefaultTeamUserRole, dryRun, true); err != nil { + if err := a.importRole(rctx, data.DefaultTeamUserRole, dryRun); err != nil { return err } if data.DefaultTeamGuestRole == nil { data.DefaultTeamGuestRole = &imports.RoleImportData{ - DisplayName: model.NewString("Team Guest Role for Scheme"), + DisplayName: model.NewString("Team Guest Role for Scheme"), + SchemeManaged: model.NewBool(true), } } data.DefaultTeamGuestRole.Name = &scheme.DefaultTeamGuestRole - if err := a.importRole(rctx, data.DefaultTeamGuestRole, dryRun, true); err != nil { + if err := a.importRole(rctx, data.DefaultTeamGuestRole, dryRun); err != nil { return err } } if scheme.Scope == model.SchemeScopeTeam || scheme.Scope == model.SchemeScopeChannel { data.DefaultChannelAdminRole.Name = &scheme.DefaultChannelAdminRole - if err := a.importRole(rctx, data.DefaultChannelAdminRole, dryRun, true); err != nil { + if err := a.importRole(rctx, data.DefaultChannelAdminRole, dryRun); err != nil { return err } data.DefaultChannelUserRole.Name = &scheme.DefaultChannelUserRole - if err := a.importRole(rctx, data.DefaultChannelUserRole, dryRun, true); err != nil { + if err := a.importRole(rctx, data.DefaultChannelUserRole, dryRun); err != nil { return err } if data.DefaultChannelGuestRole == nil { data.DefaultChannelGuestRole = &imports.RoleImportData{ - DisplayName: model.NewString("Channel Guest Role for Scheme"), + DisplayName: model.NewString("Channel Guest Role for Scheme"), + SchemeManaged: model.NewBool(true), } } data.DefaultChannelGuestRole.Name = &scheme.DefaultChannelGuestRole - if err := a.importRole(rctx, data.DefaultChannelGuestRole, dryRun, true); err != nil { + if err := a.importRole(rctx, data.DefaultChannelGuestRole, dryRun); err != nil { return err } } @@ -118,18 +120,16 @@ func (a *App) importScheme(rctx request.CTX, data *imports.SchemeImportData, dry return nil } -func (a *App) importRole(rctx request.CTX, data *imports.RoleImportData, dryRun bool, isSchemeRole bool) *model.AppError { +func (a *App) importRole(rctx request.CTX, data *imports.RoleImportData, dryRun bool) *model.AppError { var fields []mlog.Field if data != nil && data.Name != nil { fields = append(fields, mlog.String("role_name", *data.Name)) } - if !isSchemeRole { - rctx.Logger().Info("Validating role", fields...) + rctx.Logger().Info("Validating role", fields...) - if err := imports.ValidateRoleImportData(data); err != nil { - return err - } + if err := imports.ValidateRoleImportData(data); err != nil { + return err } // If this is a Dry Run, do not continue any further. @@ -158,10 +158,8 @@ func (a *App) importRole(rctx request.CTX, data *imports.RoleImportData, dryRun role.Permissions = *data.Permissions } - if isSchemeRole { - role.SchemeManaged = true - } else { - role.SchemeManaged = false + if data.SchemeManaged != nil { + role.SchemeManaged = *data.SchemeManaged } if role.Id == "" { diff --git a/server/channels/app/import_functions_test.go b/server/channels/app/import_functions_test.go index fb6d563882..7d3f73553e 100644 --- a/server/channels/app/import_functions_test.go +++ b/server/channels/app/import_functions_test.go @@ -413,7 +413,7 @@ func TestImportImportRole(t *testing.T) { Name: &rid1, } - err := th.App.importRole(th.Context, &data, true, false) + err := th.App.importRole(th.Context, &data, true) require.NotNil(t, err, "Should have failed to import.") _, nErr := th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -422,7 +422,7 @@ func TestImportImportRole(t *testing.T) { // Try importing the valid role in dryRun mode. data.DisplayName = ptrStr("display name") - err = th.App.importRole(th.Context, &data, true, false) + err = th.App.importRole(th.Context, &data, true) require.Nil(t, err, "Should have succeeded.") _, nErr = th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -431,7 +431,7 @@ func TestImportImportRole(t *testing.T) { // Try importing an invalid role. data.DisplayName = nil - err = th.App.importRole(th.Context, &data, false, false) + err = th.App.importRole(th.Context, &data, false) require.NotNil(t, err, "Should have failed to import.") _, nErr = th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -442,7 +442,7 @@ func TestImportImportRole(t *testing.T) { data.Description = ptrStr("description") data.Permissions = &[]string{"invite_user", "add_user_to_team"} - err = th.App.importRole(th.Context, &data, false, false) + err = th.App.importRole(th.Context, &data, false) require.Nil(t, err, "Should have succeeded.") role, nErr := th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -459,8 +459,9 @@ func TestImportImportRole(t *testing.T) { data.DisplayName = ptrStr("new display name") data.Description = ptrStr("description") data.Permissions = &[]string{"manage_slash_commands"} + data.SchemeManaged = model.NewBool(true) - err = th.App.importRole(th.Context, &data, false, true) + err = th.App.importRole(th.Context, &data, false) require.Nil(t, err, "Should have succeeded. %v", err) role, nErr = th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -479,7 +480,7 @@ func TestImportImportRole(t *testing.T) { DisplayName: ptrStr("new display name again"), } - err = th.App.importRole(th.Context, &data2, false, false) + err = th.App.importRole(th.Context, &data2, false) require.Nil(t, err, "Should have succeeded.") role, nErr = th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -490,7 +491,7 @@ func TestImportImportRole(t *testing.T) { assert.Equal(t, *data.Description, role.Description) assert.Equal(t, *data.Permissions, role.Permissions) assert.False(t, role.BuiltIn) - assert.False(t, role.SchemeManaged) + assert.True(t, role.SchemeManaged) } func TestImportImportTeam(t *testing.T) { diff --git a/server/channels/app/imports/import_types.go b/server/channels/app/imports/import_types.go index 1e4caf1ae0..a08397975b 100644 --- a/server/channels/app/imports/import_types.go +++ b/server/channels/app/imports/import_types.go @@ -14,6 +14,7 @@ import ( type LineImportData struct { Type string `json:"type"` + Role *RoleImportData `json:"role,omitempty"` Scheme *SchemeImportData `json:"scheme,omitempty"` Team *TeamImportData `json:"team,omitempty"` Channel *ChannelImportData `json:"channel,omitempty"` @@ -208,10 +209,11 @@ type SchemeImportData struct { } type RoleImportData struct { - Name *string `json:"name"` - DisplayName *string `json:"display_name"` - Description *string `json:"description"` - Permissions *[]string `json:"permissions"` + Name *string `json:"name"` + DisplayName *string `json:"display_name"` + Description *string `json:"description"` + Permissions *[]string `json:"permissions"` + SchemeManaged *bool `json:"scheme_managed"` } type LineImportWorkerData struct { diff --git a/server/channels/jobs/export_process/worker.go b/server/channels/jobs/export_process/worker.go index 607f44dd40..be84a72f3b 100644 --- a/server/channels/jobs/export_process/worker.go +++ b/server/channels/jobs/export_process/worker.go @@ -48,6 +48,11 @@ func MakeWorker(jobServer *jobs.JobServer, app AppIface) *jobs.SimpleWorker { opts.IncludeProfilePictures = true } + includeRolesAndSchemes, ok := job.Data["include_roles_and_schemes"] + if ok && includeRolesAndSchemes == "true" { + opts.IncludeRolesAndSchemes = true + } + outPath := *app.Config().ExportSettings.Directory exportFilename := job.Id + "_export.zip" diff --git a/server/cmd/mmctl/commands/export.go b/server/cmd/mmctl/commands/export.go index 869bee1259..9b3ed0f5c3 100644 --- a/server/cmd/mmctl/commands/export.go +++ b/server/cmd/mmctl/commands/export.go @@ -102,6 +102,7 @@ func init() { ExportCreateCmd.Flags().Bool("no-attachments", false, "Exclude file attachments from the export file.") ExportCreateCmd.Flags().Bool("include-archived-channels", false, "Include archived channels in the export file.") ExportCreateCmd.Flags().Bool("include-profile-pictures", false, "Include profile pictures in the export file.") + ExportCreateCmd.Flags().Bool("no-roles-and-schemes", false, "Exclude roles and custom permission schemes from the export file.") ExportDownloadCmd.Flags().Bool("resume", false, "Set to true to resume an export download.") _ = ExportDownloadCmd.Flags().MarkHidden("resume") @@ -138,6 +139,11 @@ func exportCreateCmdF(c client.Client, command *cobra.Command, args []string) er data["include_attachments"] = "true" } + excludeRolesAndSchemes, _ := command.Flags().GetBool("no-roles-and-schemes") + if !excludeRolesAndSchemes { + data["include_roles_and_schemes"] = "true" + } + includeArchivedChannels, _ := command.Flags().GetBool("include-archived-channels") if includeArchivedChannels { data["include_archived_channels"] = "true" diff --git a/server/cmd/mmctl/commands/export_e2e_test.go b/server/cmd/mmctl/commands/export_e2e_test.go index 52660c75c9..04d3913cd6 100644 --- a/server/cmd/mmctl/commands/export_e2e_test.go +++ b/server/cmd/mmctl/commands/export_e2e_test.go @@ -145,6 +145,7 @@ func (s *MmctlE2ETestSuite) TestExportCreateCmdF() { s.Require().Len(printer.GetLines(), 1) s.Require().Empty(printer.GetErrorLines()) s.Require().Equal("true", printer.GetLines()[0].(*model.Job).Data["include_attachments"]) + s.Require().Equal("true", printer.GetLines()[0].(*model.Job).Data["include_roles_and_schemes"]) }) s.RunForSystemAdminAndLocal("MM-T3878 - create export without attachments", func(c client.Client) { @@ -158,7 +159,21 @@ func (s *MmctlE2ETestSuite) TestExportCreateCmdF() { s.Require().Nil(err) s.Require().Len(printer.GetLines(), 1) s.Require().Empty(printer.GetErrorLines()) - s.Require().Empty(printer.GetLines()[0].(*model.Job).Data) + s.Require().Equal("", printer.GetLines()[0].(*model.Job).Data["include_attachments"]) + }) + + s.RunForSystemAdminAndLocal("create export without roles and schemes", func(c client.Client) { + printer.Clean() + + cmd := &cobra.Command{} + + cmd.Flags().Bool("no-roles-and-schemes", true, "") + + err := exportCreateCmdF(c, cmd, nil) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Empty(printer.GetErrorLines()) + s.Require().Equal("", printer.GetLines()[0].(*model.Job).Data["include_roles_and_schemes"]) }) } diff --git a/server/cmd/mmctl/commands/export_test.go b/server/cmd/mmctl/commands/export_test.go index 09014b96db..24db60d8a6 100644 --- a/server/cmd/mmctl/commands/export_test.go +++ b/server/cmd/mmctl/commands/export_test.go @@ -19,7 +19,10 @@ func (s *MmctlUnitTestSuite) TestExportCreateCmdF() { printer.Clean() mockJob := &model.Job{ Type: model.JobTypeExportProcess, - Data: map[string]string{"include_attachments": "true"}, + Data: map[string]string{ + "include_attachments": "true", + "include_roles_and_schemes": "true", + }, } s.client. @@ -39,7 +42,9 @@ func (s *MmctlUnitTestSuite) TestExportCreateCmdF() { printer.Clean() mockJob := &model.Job{ Type: model.JobTypeExportProcess, - Data: make(map[string]string), + Data: map[string]string{ + "include_roles_and_schemes": "true", + }, } s.client. @@ -57,6 +62,31 @@ func (s *MmctlUnitTestSuite) TestExportCreateCmdF() { s.Empty(printer.GetErrorLines()) s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) }) + + s.Run("create export without roles and schemes", func() { + printer.Clean() + mockJob := &model.Job{ + Type: model.JobTypeExportProcess, + Data: map[string]string{ + "include_attachments": "true", + }, + } + + s.client. + EXPECT(). + CreateJob(context.TODO(), mockJob). + Return(mockJob, &model.Response{}, nil). + Times(1) + + cmd := &cobra.Command{} + cmd.Flags().Bool("no-roles-and-schemes", true, "") + + err := exportCreateCmdF(s.client, cmd, nil) + s.Require().Nil(err) + s.Len(printer.GetLines(), 1) + s.Empty(printer.GetErrorLines()) + s.Equal(mockJob, printer.GetLines()[0].(*model.Job)) + }) } func (s *MmctlUnitTestSuite) TestExportDeleteCmdF() { diff --git a/server/cmd/mmctl/commands/import.go b/server/cmd/mmctl/commands/import.go index 509bfe0eb0..e802c82d14 100644 --- a/server/cmd/mmctl/commands/import.go +++ b/server/cmd/mmctl/commands/import.go @@ -366,6 +366,7 @@ func importJobListCmdF(c client.Client, command *cobra.Command, args []string) e } type Statistics struct { + Roles uint64 `json:"roles"` Schemes uint64 `json:"schemes"` Teams uint64 `json:"teams"` Channels uint64 `json:"channels"` @@ -495,6 +496,7 @@ func importValidateCmdF(command *cobra.Command, args []string) error { } stat := Statistics{ + Roles: validator.Roles(), Schemes: validator.Schemes(), Teams: validator.TeamCount(), Channels: validator.ChannelCount(), @@ -542,6 +544,7 @@ func configurePrinter() { func printStatistics(stat Statistics) { tmpl := "\n" + + "Roles {{ .Roles }}\n" + "Schemes {{ .Schemes }}\n" + "Teams {{ .Teams }}\n" + "Channels {{ .Channels }}\n" + diff --git a/server/cmd/mmctl/commands/importer/validate.go b/server/cmd/mmctl/commands/importer/validate.go index d24843a4cc..8fec1a3186 100644 --- a/server/cmd/mmctl/commands/importer/validate.go +++ b/server/cmd/mmctl/commands/importer/validate.go @@ -58,6 +58,7 @@ type Validator struct { //nolint:govet attachmentsUsed map[string]uint64 allFileNames []string + roles map[string]ImportFileInfo schemes map[string]ImportFileInfo teams map[string]ImportFileInfo channels map[ChannelTeam]ImportFileInfo @@ -75,6 +76,7 @@ type Validator struct { //nolint:govet const ( LineTypeVersion = "version" + LineTypeRole = "role" LineTypeScheme = "scheme" LineTypeTeam = "team" LineTypeChannel = "channel" @@ -110,6 +112,7 @@ func NewValidator( attachments: make(map[string]*zip.File), attachmentsUsed: make(map[string]uint64), + roles: map[string]ImportFileInfo{}, schemes: map[string]ImportFileInfo{}, teams: map[string]ImportFileInfo{}, channels: map[ChannelTeam]ImportFileInfo{}, @@ -121,6 +124,10 @@ func NewValidator( return v } +func (v *Validator) Roles() uint64 { + return uint64(len(v.roles)) +} + func (v *Validator) Schemes() uint64 { return uint64(len(v.schemes)) } @@ -388,6 +395,8 @@ func (v *Validator) validateLine(info ImportFileInfo, line imports.LineImportDat switch line.Type { case LineTypeVersion: err = v.validateVersion(info, line) + case LineTypeRole: + err = v.validateRole(info, line) case LineTypeScheme: err = v.validateScheme(info, line) case LineTypeTeam: @@ -444,6 +453,37 @@ func (v *Validator) validateVersion(info ImportFileInfo, line imports.LineImport return nil } +func (v *Validator) validateRole(info ImportFileInfo, line imports.LineImportData) (err error) { + ivErr := validateNotNil(info, "role", line.Role, func(data imports.RoleImportData) *ImportValidationError { + appErr := imports.ValidateRoleImportData(&data) + if appErr != nil { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "role", + Err: appErr, + } + } + + if data.Name != nil { + if existing, ok := v.roles[*data.Name]; ok { + return &ImportValidationError{ + ImportFileInfo: info, + FieldName: "role", + Err: fmt.Errorf("duplicate entry, previous was in line: %d", existing.CurrentLine), + } + } + v.roles[*data.Name] = info + } + + return nil + }) + if ivErr != nil { + return v.onError(ivErr) + } + + return nil +} + func (v *Validator) validateScheme(info ImportFileInfo, line imports.LineImportData) (err error) { ivErr := validateNotNil(info, "scheme", line.Scheme, func(data imports.SchemeImportData) *ImportValidationError { appErr := imports.ValidateSchemeImportData(&data) diff --git a/server/cmd/mmctl/docs/mmctl_export_create.rst b/server/cmd/mmctl/docs/mmctl_export_create.rst index c70a3f8bb3..fd618692d5 100644 --- a/server/cmd/mmctl/docs/mmctl_export_create.rst +++ b/server/cmd/mmctl/docs/mmctl_export_create.rst @@ -24,6 +24,7 @@ Options --include-archived-channels Include archived channels in the export file. --include-profile-pictures Include profile pictures in the export file. --no-attachments Exclude file attachments from the export file. + --no-roles-and-schemes Exclude roles and custom permission schemes from the export file. Options inherited from parent commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/server/i18n/en.json b/server/i18n/en.json index 0d12b0f2f8..da68882f10 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -5474,6 +5474,10 @@ "id": "app.import.import_line.null_post.error", "translation": "Import data line has type \"post\" but the post object is null." }, + { + "id": "app.import.import_line.null_role.error", + "translation": "Import data line has type \"role\" but the role object is null." + }, { "id": "app.import.import_line.null_scheme.error", "translation": "Import data line has type \"scheme\" but the scheme object is null." @@ -6662,6 +6666,10 @@ "id": "app.scheme.get.app_error", "translation": "Unable to get the scheme." }, + { + "id": "app.scheme.get_all_page.app_error", + "translation": "Unable to get page of schemes." + }, { "id": "app.scheme.permanent_delete_all.app_error", "translation": "We could not permanently delete the schemes." @@ -9790,6 +9798,10 @@ "id": "model.reporting_base_options.is_valid.bad_date_range", "translation": "Date range provided is invalid." }, + { + "id": "model.scheme.is_valid.app_error", + "translation": "Invalid scheme." + }, { "id": "model.search_params_list.is_valid.include_deleted_channels.app_error", "translation": "All IncludeDeletedChannels params should have the same value." diff --git a/server/public/model/bulk_export.go b/server/public/model/bulk_export.go index b5bf5a4b9c..31d8a8500d 100644 --- a/server/public/model/bulk_export.go +++ b/server/public/model/bulk_export.go @@ -11,5 +11,6 @@ type BulkExportOpts struct { IncludeAttachments bool IncludeProfilePictures bool IncludeArchivedChannels bool + IncludeRolesAndSchemes bool CreateArchive bool }