Edit attachment permission (#36227) (#36727)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (shard 0) (push) Blocked by required conditions
Server CI / Postgres (shard 1) (push) Blocked by required conditions
Server CI / Postgres (shard 2) (push) Blocked by required conditions
Server CI / Postgres (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres Test Results (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 0) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 1) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 2) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres FIPS Test Results (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions

Automatic Merge
This commit is contained in:
Harshil Sharma 2026-05-25 15:54:15 +05:30 committed by GitHub
parent f3816c1b60
commit 49fdffda8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 570 additions and 31 deletions

View file

@ -10,8 +10,8 @@ import xor from 'lodash.xor';
export const defaultRolesPermissions = {
channel_admin: 'use_channel_mentions remove_reaction manage_public_channel_members use_group_mentions manage_channel_roles manage_private_channel_members add_reaction read_public_channel_groups create_post read_private_channel_groups add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel manage_public_channel_auto_translation manage_private_channel_auto_translation',
channel_guest: 'upload_file edit_post create_post use_channel_mentions read_channel read_channel_content add_reaction remove_reaction',
channel_user: 'manage_private_channel_members read_public_channel_groups delete_post read_private_channel_groups use_group_mentions manage_private_channel_properties delete_public_channel add_reaction manage_public_channel_properties edit_post upload_file use_channel_mentions get_public_link read_channel read_channel_content delete_private_channel manage_public_channel_members create_post remove_reaction add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel',
channel_guest: 'upload_file edit_post create_post use_channel_mentions read_channel read_channel_content add_reaction remove_reaction edit_file_attachment',
channel_user: 'manage_private_channel_members read_public_channel_groups delete_post read_private_channel_groups use_group_mentions manage_private_channel_properties delete_public_channel add_reaction manage_public_channel_properties edit_post upload_file use_channel_mentions get_public_link read_channel read_channel_content delete_private_channel manage_public_channel_members create_post remove_reaction add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel edit_file_attachment',
custom_group_user: '',
playbook_admin: 'playbook_private_manage_properties playbook_public_make_private playbook_public_manage_members playbook_public_manage_roles playbook_public_manage_properties playbook_private_manage_members playbook_private_manage_roles',
playbook_member: 'playbook_public_view playbook_public_manage_members playbook_public_manage_properties playbook_private_view playbook_private_manage_members playbook_private_manage_properties run_create',

View file

@ -8,6 +8,7 @@ import (
"net/http"
"testing"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@ -1043,28 +1044,10 @@ func TestAddChannelsToPolicy(t *testing.T) {
validChannelIDs := []string{model.NewId(), model.NewId()}
invalidChannelIDs := []string{"invalid_channel_id"}
// Custom function to compare slices regardless of order
unorderedSlicesEqual := func(a, b []string) bool {
if len(a) != len(b) {
return false
}
counts := make(map[string]int)
for _, item := range a {
counts[item]++
}
for _, item := range b {
counts[item]--
if counts[item] < 0 {
return false
}
}
return true
}
// Custom matcher for unordered slice comparison
unorderedSliceMatcher := func(expected []string) func(actual []string) bool {
return func(actual []string) bool {
return unorderedSlicesEqual(expected, actual)
return utils.SliceEqualUnordered(expected, actual)
}
}

View file

@ -1090,6 +1090,11 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
checkEditFileAttachmentPermission(c, post.FileIds, originalPost)
if c.Err != nil {
return
}
if c.AppContext.Session().UserId != originalPost.UserId {
// We don't need to check the member here, since we already checked it above
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditOthersPosts); !ok {
@ -1164,6 +1169,11 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
checkEditFileAttachmentPermission(c, *post.FileIds, originalPost)
if c.Err != nil {
return
}
}
patchedPost, isMemberForPReviews, err := c.App.PatchPost(c.AppContext, c.Params.PostId, c.App.PostPatchWithProxyRemovedFromImageURLs(&post), nil)

View file

@ -1961,6 +1961,124 @@ func TestUpdatePost(t *testing.T) {
require.Equal(t, int64(0), postFileInfos[0].DeleteAt)
})
t.Run("should prevent adding files when edit_file_attachment permission is revoked", func(t *testing.T) {
th.LoginBasic(t)
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "Post without files",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
th.RemovePermissionFromRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
defer th.AddPermissionToRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
updatePost := &model.Post{
Id: postWithoutFiles.Id,
ChannelId: channel.Id,
Message: "Updated post with file",
FileIds: model.StringArray{fileId},
}
_, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "You do not have the appropriate permissions.", err.Error())
CheckForbiddenStatus(t, resp)
})
t.Run("should prevent removing files when edit_file_attachment permission is revoked", func(t *testing.T) {
th.LoginBasic(t)
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
postWithFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "Post with files",
FileIds: model.StringArray{fileId},
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
th.RemovePermissionFromRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
defer th.AddPermissionToRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
updatePost := &model.Post{
Id: postWithFiles.Id,
ChannelId: channel.Id,
Message: "Updated post without file",
FileIds: model.StringArray{},
}
_, resp, err := client.UpdatePost(context.Background(), postWithFiles.Id, updatePost)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, "You do not have the appropriate permissions.", err.Error())
CheckForbiddenStatus(t, resp)
})
t.Run("should allow updating post with unchanged files when edit_file_attachment permission is revoked", func(t *testing.T) {
th.LoginBasic(t)
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
postWithFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "Post with files",
FileIds: model.StringArray{fileId},
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
th.RemovePermissionFromRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
defer th.AddPermissionToRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
updatePost := &model.Post{
Id: postWithFiles.Id,
ChannelId: channel.Id,
Message: "Updated message only",
FileIds: model.StringArray{fileId},
}
updatedPost, resp, err := client.UpdatePost(context.Background(), postWithFiles.Id, updatePost)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, updatedPost)
assert.Equal(t, "Updated message only", updatedPost.Message)
})
t.Run("should allow changing files when edit_file_attachment permission is present", func(t *testing.T) {
th.LoginBasic(t)
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "Post without files",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
updatePost := &model.Post{
Id: postWithoutFiles.Id,
ChannelId: channel.Id,
Message: "Updated post with file",
FileIds: model.StringArray{fileId},
}
updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, updatedPost)
})
t.Run("should be able to add and remove files simultaneously", func(t *testing.T) {
th.LoginBasic(t)
// create new file
@ -2218,6 +2336,129 @@ func TestPatchPost(t *testing.T) {
CheckForbiddenStatus(t, resp)
})
t.Run("should prevent patching file ids when edit_file_attachment permission is revoked", func(t *testing.T) {
th.LoginBasic(t)
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
ChannelId: channel.Id,
Message: "original message",
})
require.NoError(t, err)
th.RemovePermissionFromRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
defer th.AddPermissionToRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
patch := &model.PostPatch{
FileIds: &model.StringArray{fileId},
}
_, resp, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("should prevent removing files via patch when edit_file_attachment permission is revoked", func(t *testing.T) {
th.LoginBasic(t)
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
ChannelId: channel.Id,
Message: "post with file",
FileIds: model.StringArray{fileId},
})
require.NoError(t, err)
th.RemovePermissionFromRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
defer th.AddPermissionToRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
emptyFileIds := model.StringArray{}
patch := &model.PostPatch{
FileIds: &emptyFileIds,
}
_, resp, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("should allow patching message without file change when edit_file_attachment permission is revoked", func(t *testing.T) {
th.LoginBasic(t)
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
ChannelId: channel.Id,
Message: "original message",
FileIds: model.StringArray{fileId},
})
require.NoError(t, err)
th.RemovePermissionFromRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
defer th.AddPermissionToRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
patch := &model.PostPatch{
Message: model.NewPointer("updated message only"),
}
patchedPost, _, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
require.NoError(t, err)
assert.Equal(t, "updated message only", patchedPost.Message)
})
t.Run("should allow patching with same file ids when edit_file_attachment permission is revoked", func(t *testing.T) {
th.LoginBasic(t)
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
ChannelId: channel.Id,
Message: "original message",
FileIds: model.StringArray{fileId},
})
require.NoError(t, err)
th.RemovePermissionFromRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
defer th.AddPermissionToRole(t, model.PermissionEditFileAttachment.Id, model.ChannelUserRoleId)
sameFileIds := model.StringArray{fileId}
patch := &model.PostPatch{
Message: model.NewPointer("updated message"),
FileIds: &sameFileIds,
}
patchedPost, _, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
require.NoError(t, err)
assert.Equal(t, "updated message", patchedPost.Message)
})
t.Run("should allow patching files when edit_file_attachment permission is present", func(t *testing.T) {
th.LoginBasic(t)
fileResp, _, err := client.UploadFile(context.Background(), data, channel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
postToEdit, _, err := client.CreatePost(context.Background(), &model.Post{
ChannelId: channel.Id,
Message: "original message",
})
require.NoError(t, err)
patch := &model.PostPatch{
FileIds: &model.StringArray{fileId},
}
patchedPost, _, err := client.PatchPost(context.Background(), postToEdit.Id, patch)
require.NoError(t, err)
require.NotNil(t, patchedPost)
})
t.Run("time limit expired", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.PostEditTimeLimit = 1

View file

@ -6,6 +6,7 @@ package api4
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
func userCreatePostPermissionCheckWithContext(c *Context, channelId string) {
@ -77,3 +78,14 @@ func checkUploadFilePermissionForNewFiles(c *Context, newFileIds []string, origi
}
}
}
// checkEditFileAttachmentPermission checks edit_file_attachment permission
// when file IDs are being changed (files added or removed) during post edit.
func checkEditFileAttachmentPermission(c *Context, newFileIds []string, originalPost *model.Post) {
if utils.SliceEqualUnordered(newFileIds, originalPost.FileIds) {
return
}
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditFileAttachment); !ok {
c.SetPermissionError(model.PermissionEditFileAttachment)
}
}

View file

@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/stretchr/testify/assert"
)
func TestSameFileIDs(t *testing.T) {
tests := []struct {
name string
a []string
b []string
expected bool
}{
{
name: "both empty",
a: []string{},
b: []string{},
expected: true,
},
{
name: "both nil",
a: nil,
b: nil,
expected: true,
},
{
name: "same files same order",
a: []string{"file1", "file2", "file3"},
b: []string{"file1", "file2", "file3"},
expected: true,
},
{
name: "same files different order",
a: []string{"file3", "file1", "file2"},
b: []string{"file1", "file2", "file3"},
expected: true,
},
{
name: "one file added",
a: []string{"file1", "file2", "file3"},
b: []string{"file1", "file2"},
expected: false,
},
{
name: "one file removed",
a: []string{"file1"},
b: []string{"file1", "file2"},
expected: false,
},
{
name: "different files same length",
a: []string{"file1", "file2"},
b: []string{"file1", "file3"},
expected: false,
},
{
name: "duplicate IDs in a",
a: []string{"file1", "file1"},
b: []string{"file1", "file2"},
expected: false,
},
{
name: "duplicate IDs same in both",
a: []string{"file1", "file1"},
b: []string{"file1", "file1"},
expected: true,
},
{
name: "empty vs non-empty",
a: []string{},
b: []string{"file1"},
expected: false,
},
{
name: "nil vs non-empty",
a: nil,
b: []string{"file1"},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := utils.SliceEqualUnordered(tc.a, tc.b)
assert.Equal(t, tc.expected, result)
})
}
}

View file

@ -125,6 +125,7 @@ func TestDoAdvancedPermissionsMigration(t *testing.T) {
model.PermissionManagePrivateChannelMembers.Id,
model.PermissionDeletePost.Id,
model.PermissionEditPost.Id,
model.PermissionEditFileAttachment.Id,
model.PermissionAddBookmarkPublicChannel.Id,
model.PermissionEditBookmarkPublicChannel.Id,
model.PermissionDeleteBookmarkPublicChannel.Id,

View file

@ -1293,6 +1293,15 @@ func (a *App) getRestoreManageOAuthPermissionMigration() (permissionsMap, error)
}, nil
}
func (a *App) getAddEditFileAttachmentPermissionMigration() (permissionsMap, error) {
return permissionsMap{
permissionTransformation{
On: permissionExists(model.PermissionEditPost.Id),
Add: []string{model.PermissionEditFileAttachment.Id},
},
}, nil
}
// DoPermissionsMigrations execute all the permissions migrations need by the current version.
func (a *App) DoPermissionsMigrations() error {
return a.Srv().doPermissionsMigrations()
@ -1353,6 +1362,7 @@ func (s *Server) doPermissionsMigrations() error {
{Key: model.MigrationKeyAddSharedChannelManagerPermissions, Migration: a.getAddSharedChannelManagerPermissionsMigration},
{Key: model.MigrationKeyAddSecureConnectionManagerPermissions, Migration: a.getAddSecureConnectionManagerPermissionsMigration},
{Key: model.MigrationKeyRestoreManageOAuthPermission, Migration: a.getRestoreManageOAuthPermissionMigration},
{Key: model.MigrationKeyAddEditFileAttachmentPermission, Migration: a.getAddEditFileAttachmentPermissionMigration},
}
roles, err := s.Store().Role().GetAll()

View file

@ -96,6 +96,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store {
systemStore.On("GetByName", model.MigrationKeyAddChannelAccessRulesPermission).Return(&model.System{Name: model.MigrationKeyAddChannelAccessRulesPermission, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyAddChannelAutoTranslationPermissions).Return(&model.System{Name: model.MigrationKeyAddChannelAutoTranslationPermissions, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyRestoreManageOAuthPermission).Return(&model.System{Name: model.MigrationKeyRestoreManageOAuthPermission, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyAddEditFileAttachmentPermission).Return(&model.System{Name: model.MigrationKeyAddEditFileAttachmentPermission, Value: "true"}, nil)
systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(&model.System{}, nil).Once()
systemStore.On("Save", mock.AnythingOfType("*model.System")).Return(nil)

View file

@ -277,3 +277,22 @@ func RoundOffToZeroesResolution(n float64, minResolution int) int64 {
significantDigits := int64(n) / tens
return significantDigits * tens
}
// SliceEqualUnordered returns true if both slices contain the same set of elements,
// regardless of order.
func SliceEqualUnordered[K comparable](a, b []K) bool {
if len(a) != len(b) {
return false
}
set := make(map[K]int, len(a))
for _, id := range a {
set[id]++
}
for _, id := range b {
set[id]--
if set[id] < 0 {
return false
}
}
return true
}

View file

@ -61,4 +61,5 @@ const (
MigrationKeyAddSharedChannelManagerPermissions = "shared_channel_manager_permissions"
MigrationKeyAddSecureConnectionManagerPermissions = "secure_connection_manager_permissions"
MigrationKeyRestoreManageOAuthPermission = "restore_manage_oauth_permission"
MigrationKeyAddEditFileAttachmentPermission = "add_edit_file_attachment_permission"
)

View file

@ -177,6 +177,7 @@ var PermissionManageLicenseInformation *Permission
var PermissionManagePublicChannelBanner *Permission
var PermissionManagePrivateChannelBanner *Permission
var PermissionManageChannelAccessRules *Permission
var PermissionEditFileAttachment *Permission
var PermissionSysconsoleReadAbout *Permission
var PermissionSysconsoleWriteAbout *Permission
@ -1356,6 +1357,13 @@ func initializePermissions() {
PermissionScopeChannel,
}
PermissionEditFileAttachment = &Permission{
"edit_file_attachment",
"",
"",
PermissionScopeChannel,
}
PermissionReadOtherUsersTeams = &Permission{
"read_other_users_teams",
"authentication.permissions.read_other_users_teams.name",
@ -2597,6 +2605,7 @@ func initializePermissions() {
PermissionManagePublicChannelBanner,
PermissionManagePrivateChannelBanner,
PermissionManageChannelAccessRules,
PermissionEditFileAttachment,
}
GroupScopedPermissions := []*Permission{

View file

@ -876,6 +876,7 @@ func MakeDefaultRoles() map[string]*Role {
PermissionEditPost.Id,
PermissionCreatePost.Id,
PermissionUseChannelMentions.Id,
PermissionEditFileAttachment.Id,
},
SchemeManaged: true,
BuiltIn: true,
@ -902,6 +903,7 @@ func MakeDefaultRoles() map[string]*Role {
PermissionManagePrivateChannelMembers.Id,
PermissionDeletePost.Id,
PermissionEditPost.Id,
PermissionEditFileAttachment.Id,
PermissionAddBookmarkPublicChannel.Id,
PermissionEditBookmarkPublicChannel.Id,
PermissionDeleteBookmarkPublicChannel.Id,

View file

@ -71,6 +71,7 @@ describe('components/admin_console/permission_schemes_settings/guest_permissions
expect(permissionIds).toContain('guest_create_post');
expect(permissionIds).toContain('guest_reactions');
expect(permissionIds).toContain('guest_use_channel_mentions');
expect(permissionIds).toContain('guest_edit_file_attachment');
if (includeGroupMentions) {
expect(permissionIds).toContain('guest_use_group_mentions');

View file

@ -32,6 +32,7 @@ const GuestPermissionsTree = ({license, onToggle, readOnly, scope, selectRow, pa
const defaultPermissions = [
Permissions.CREATE_PRIVATE_CHANNEL,
Permissions.EDIT_POST,
Permissions.EDIT_FILE_ATTACHMENT,
Permissions.DELETE_POST,
{
id: 'guest_' + Permissions.CREATE_POST,

View file

@ -18,6 +18,7 @@ export const GUEST_INCLUDED_PERMISSIONS = [
Permissions.REMOVE_REACTION,
Permissions.READ_CHANNEL,
Permissions.UPLOAD_FILE,
Permissions.EDIT_FILE_ATTACHMENT,
Permissions.USE_CHANNEL_MENTIONS,
Permissions.USE_GROUP_MENTIONS,
Permissions.CREATE_POST,

View file

@ -128,6 +128,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_tree sh
"edit_others_posts",
],
},
"edit_file_attachment",
Object {
"id": "delete_posts",
"permissions": Array [
@ -322,6 +323,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_tree sh
"edit_others_posts",
],
},
"edit_file_attachment",
Object {
"id": "delete_posts",
"permissions": Array [
@ -532,6 +534,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_tree sh
"edit_others_posts",
],
},
"edit_file_attachment",
Object {
"id": "delete_posts",
"permissions": Array [
@ -753,6 +756,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_tree sh
"edit_others_posts",
],
},
"edit_file_attachment",
Object {
"id": "delete_posts",
"permissions": Array [
@ -974,6 +978,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_tree sh
"edit_others_posts",
],
},
"edit_file_attachment",
Object {
"id": "delete_posts",
"permissions": Array [
@ -1195,6 +1200,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_tree sh
"edit_others_posts",
],
},
"edit_file_attachment",
Object {
"id": "delete_posts",
"permissions": Array [
@ -1423,6 +1429,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_tree sh
"edit_others_posts",
],
},
"edit_file_attachment",
Object {
"id": "delete_posts",
"permissions": Array [

View file

@ -130,6 +130,17 @@ describe('components/admin_console/permission_schemes_settings/permission_tree',
}
});
test('should include edit_file_attachment in the posts permission group', () => {
const wrapper = shallow(
<PermissionsTree {...defaultProps}/>,
);
const groups = wrapper.find(PermissionGroup).first().prop('permissions') as Array<Group | Permission>;
const postsGroup = groups[6];
expect(postsGroup.id).toBe('posts');
expect(postsGroup.permissions).toContain('edit_file_attachment');
});
test('should hide disabbled integration options', () => {
const wrapper = shallow(
<PermissionsTree

View file

@ -160,6 +160,7 @@ export default class PermissionsTree extends React.PureComponent<Props, State> {
Permissions.EDIT_OTHERS_POSTS,
],
},
Permissions.EDIT_FILE_ATTACHMENT,
{
id: 'delete_posts',
permissions: [

View file

@ -165,6 +165,16 @@ export const groupRolesStrings: Record<string, Record<string, MessageDescriptor>
defaultMessage: 'Add and delete reactions on posts.',
},
}),
guest_edit_file_attachment: defineMessages({
name: {
id: 'admin.permissions.group.guest_edit_file_attachment.name',
defaultMessage: 'Edit Attachments',
},
description: {
id: 'admin.permissions.group.guest_edit_file_attachment.description',
defaultMessage: 'Allow users to add or remove file attachments when editing posts.',
},
}),
guest_create_post: defineMessages({
name: {
id: 'admin.permissions.group.guest_create_post.name',

View file

@ -495,6 +495,16 @@ export const permissionRolesStrings: Record<string, Record<string, MessageDescri
defaultMessage: 'Upload file',
},
}),
edit_file_attachment: defineMessages({
name: {
id: 'admin.permissions.permission.edit_file_attachment.name',
defaultMessage: 'Edit Attachments',
},
description: {
id: 'admin.permissions.permission.edit_file_attachment.description',
defaultMessage: 'Allow users to add or remove file attachments when editing posts.',
},
}),
use_channel_mentions: defineMessages({
name: {
id: 'admin.permissions.permission.use_channel_mentions.name',

View file

@ -1,12 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef, useState} from 'react';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import type {ServerError} from '@mattermost/types/errors';
import type {FileInfo} from '@mattermost/types/files';
import Permissions from 'mattermost-redux/constants/permissions';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {sortFileInfos} from 'mattermost-redux/utils/file_utils';
import {getCurrentLocale} from 'selectors/i18n';
@ -17,6 +21,7 @@ import FileUpload from 'components/file_upload';
import type {FileUpload as FileUploadClass, TextEditorLocationType} from 'components/file_upload/file_upload';
import type TextboxClass from 'components/textbox/textbox';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
const getFileCount = (draft: PostDraft) => {
@ -36,12 +41,25 @@ const useUploadFiles = (
setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void,
isPostBeingEdited?: boolean,
): [React.ReactNode, React.ReactNode] => {
const intl = useIntl();
const locale = useSelector(getCurrentLocale);
const canEditAttachments = useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
return channel ? haveIChannelPermission(state, channel.team_id, channel.id, Permissions.EDIT_FILE_ATTACHMENT) : true;
});
const editAttachmentsDisabled = isPostBeingEdited && !canEditAttachments;
const [uploadsProgressPercent, setUploadsProgressPercent] = useState<{ [clientID: string]: FilePreviewInfo }>({});
const fileUploadRef = useRef<FileUploadClass>(null);
const removeTooltip = useMemo(() => {
return editAttachmentsDisabled ? intl.formatMessage({
id: 'file_preview.no_edit_permission',
defaultMessage: 'Post attachments cannot be edited',
}) : undefined;
}, [editAttachmentsDisabled, intl]);
const handleFileUploadChange = useCallback(() => {
focusTextbox();
}, [focusTextbox]);
@ -146,9 +164,10 @@ const useUploadFiles = (
attachmentPreview = (
<FilePreview
fileInfos={draft.fileInfos}
onRemove={removePreview}
onRemove={editAttachmentsDisabled ? undefined : removePreview}
uploadsInProgress={draft.uploadsInProgress}
uploadsProgressPercent={uploadsProgressPercent}
disabledRemoveTooltip={removeTooltip}
/>
);
}
@ -160,7 +179,7 @@ const useUploadFiles = (
postType = isThreadView ? 'thread' : 'comment';
}
const fileUploadJSX = isDisabled ? null : (
const fileUploadJSX = (isDisabled || editAttachmentsDisabled) ? null : (
<FileUpload
ref={fileUploadRef}
fileCount={getFileCount(draft)}

View file

@ -149,4 +149,62 @@ describe('FilePreview', () => {
expect(screen.getByAltText('file preview')).toHaveAttribute('src', getFileUrl(fileId));
});
test('should render disabled remove button with tooltip when onRemove is absent and disabledRemoveTooltip is provided', async () => {
const props = {
...baseProps,
onRemove: undefined,
uploadsInProgress: [],
disabledRemoveTooltip: 'You do not have permission to edit file attachments',
};
const {container} = renderWithContext(
<FilePreview {...props}/>,
);
const disabledRemove = container.querySelector('.file-preview__remove--disabled');
expect(disabledRemove).toBeInTheDocument();
expect(container.querySelector('a.file-preview__remove')).not.toBeInTheDocument();
if (!disabledRemove) {
throw new Error('Disabled remove button not found');
}
const user = userEvent.setup();
await user.hover(disabledRemove);
expect(await screen.findByText('You do not have permission to edit file attachments')).toBeInTheDocument();
expect(container.querySelector('a.file-preview__remove')).not.toBeInTheDocument();
});
test('should render normal remove button when onRemove is defined', () => {
const props = {
...baseProps,
onRemove: jest.fn(),
uploadsInProgress: [],
disabledRemoveTooltip: 'tooltip text',
};
const {container} = renderWithContext(
<FilePreview {...props}/>,
);
expect(container.querySelector('a.file-preview__remove')).toBeInTheDocument();
expect(container.querySelector('.file-preview__remove--disabled')).not.toBeInTheDocument();
});
test('should not render any remove button when onRemove is absent and no disabledRemoveTooltip', () => {
const props = {
...baseProps,
onRemove: undefined,
uploadsInProgress: [],
disabledRemoveTooltip: undefined,
};
const {container} = renderWithContext(
<FilePreview {...props}/>,
);
expect(container.querySelector('a.file-preview__remove')).not.toBeInTheDocument();
expect(container.querySelector('.file-preview__remove--disabled')).not.toBeInTheDocument();
});
});

View file

@ -9,6 +9,7 @@ import type {FileInfo} from '@mattermost/types/files';
import {getFileThumbnailUrl, getFileUrl} from 'mattermost-redux/utils/file_utils';
import FilenameOverlay from 'components/file_attachment/filename_overlay';
import WithTooltip from 'components/with_tooltip';
import Constants, {FileTypes} from 'utils/constants';
import * as Utils from 'utils/utils';
@ -28,7 +29,8 @@ type Props = {
fileInfos: FilePreviewInfo[];
uploadsInProgress?: string[];
uploadsProgressPercent?: {[clientID: string]: FilePreviewInfo};
}
disabledRemoveTooltip?: string;
};
export default class FilePreview extends React.PureComponent<Props> {
static defaultProps = {
@ -90,9 +92,7 @@ export default class FilePreview extends React.PureComponent<Props> {
key={info.id}
className={className}
>
<div className='post-image__thumbnail'>
{previewImage}
</div>
<div className='post-image__thumbnail'>{previewImage}</div>
<div className='post-image__details'>
<div className='post-image__detail_wrapper'>
<div className='post-image__detail'>
@ -101,12 +101,18 @@ export default class FilePreview extends React.PureComponent<Props> {
compactDisplay={false}
canDownload={false}
/>
{info.extension && <span className='post-image__type'>{info.extension.toUpperCase()}</span>}
<span className='post-image__size'>{Utils.fileSizeToString(info.size)}</span>
{info.extension && (
<span className='post-image__type'>
{info.extension.toUpperCase()}
</span>
)}
<span className='post-image__size'>
{Utils.fileSizeToString(info.size)}
</span>
</div>
</div>
<div>
{Boolean(this.props.onRemove) && (
{this.props.onRemove && (
<a
className='file-preview__remove'
onClick={this.handleRemove.bind(this, info.id)}
@ -114,6 +120,19 @@ export default class FilePreview extends React.PureComponent<Props> {
<i className='icon icon-close'/>
</a>
)}
{!this.props.onRemove && this.props.disabledRemoveTooltip && (
<WithTooltip
title={this.props.disabledRemoveTooltip}
isVertical={true}
>
<span
className='file-preview__remove file-preview__remove--disabled'
>
<i className='icon icon-close'/>
</span>
</WithTooltip>
)}
</div>
</div>
</div>,

View file

@ -1904,6 +1904,8 @@
"admin.permissions.group.guest_create_private_channel.name": "Create Channels",
"admin.permissions.group.guest_delete_post.description": "Author's own posts can be deleted.",
"admin.permissions.group.guest_delete_post.name": "Delete Own Posts",
"admin.permissions.group.guest_edit_file_attachment.description": "Allow users to add or remove file attachments when editing posts.",
"admin.permissions.group.guest_edit_file_attachment.name": "Edit Attachments",
"admin.permissions.group.guest_edit_post.description": "{editTimeLimitButton} after posting, allow users to edit their own posts.",
"admin.permissions.group.guest_edit_post.name": "Edit Own Posts",
"admin.permissions.group.guest_reactions.description": "Add and delete reactions on posts.",
@ -1998,6 +2000,8 @@
"admin.permissions.permission.delete_public_channel.name": "Archive Channels",
"admin.permissions.permission.edit_custom_group.description": "Rename custom groups.",
"admin.permissions.permission.edit_custom_group.name": "Edit",
"admin.permissions.permission.edit_file_attachment.description": "Allow users to add or remove file attachments when editing posts.",
"admin.permissions.permission.edit_file_attachment.name": "Edit Attachments",
"admin.permissions.permission.edit_other_users.description": "Edit other users",
"admin.permissions.permission.edit_other_users.name": "Edit other users",
"admin.permissions.permission.edit_others_posts.description": "Allow users to edit others' posts.",
@ -4521,6 +4525,7 @@
"file_preview_modal_main_nav.file": "{count, number} of {total, number}",
"file_preview_modal_main_nav.nextAriaLabel": "Next file",
"file_preview_modal_main_nav.prevAriaLabel": "Previous file",
"file_preview.no_edit_permission": "Post attachments cannot be edited",
"file_search_result_item.copy_link": "Copy link",
"file_search_result_item.download": "Download",
"file_search_result_item.more_actions": "More Actions",

View file

@ -46,6 +46,7 @@ const values = {
REMOVE_OTHERS_REACTIONS: 'remove_others_reactions',
PERMANENT_DELETE_USER: 'permanent_delete_user',
UPLOAD_FILE: 'upload_file',
EDIT_FILE_ATTACHMENT: 'edit_file_attachment',
GET_PUBLIC_LINK: 'get_public_link',
MANAGE_WEBHOOKS: 'manage_webhooks',
MANAGE_OTHERS_WEBHOOKS: 'manage_others_webhooks',

View file

@ -69,6 +69,15 @@
background: rgba(var(--button-bg-rgb), 0.08);
color: functions.v(button-bg);
}
&--disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
background: none;
}
}
}
}

View file

@ -1162,6 +1162,7 @@ export const PermissionsScope = {
[Permissions.REMOVE_OTHERS_REACTIONS]: 'channel_scope',
[Permissions.PERMANENT_DELETE_USER]: 'system_scope',
[Permissions.UPLOAD_FILE]: 'channel_scope',
[Permissions.EDIT_FILE_ATTACHMENT]: 'channel_scope',
[Permissions.GET_PUBLIC_LINK]: 'system_scope',
[Permissions.MANAGE_INCOMING_WEBHOOKS]: 'team_scope',
[Permissions.MANAGE_OWN_INCOMING_WEBHOOKS]: 'team_scope',
@ -1261,6 +1262,7 @@ export const DefaultRolePermissions = {
Permissions.MANAGE_PRIVATE_CHANNEL_MEMBERS,
Permissions.DELETE_POST,
Permissions.EDIT_POST,
Permissions.EDIT_FILE_ATTACHMENT,
Permissions.USE_CHANNEL_MENTIONS,
Permissions.USE_GROUP_MENTIONS,
Permissions.CREATE_CUSTOM_GROUP,
@ -1355,6 +1357,7 @@ export const DefaultRolePermissions = {
],
guests: [
Permissions.EDIT_POST,
Permissions.EDIT_FILE_ATTACHMENT,
Permissions.ADD_REACTION,
Permissions.REMOVE_REACTION,
Permissions.USE_CHANNEL_MENTIONS,