From d4d65c8cfb56b63cd21020f06032330e1eb7752f Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 15 Apr 2026 10:57:46 -0400 Subject: [PATCH] Add manage_own_agent and manage_others_agent permissions (#35924) * Add PermissionCreateAgent server-side permission definition Define PermissionCreateAgent in the model layer with system scope, add to SystemScopedPermissionsMinusSysconsole (feeds AllPermissions), grant to system_user in MakeDefaultRoles(), and register a permissions migration for existing installations (system_admin + system_user). Co-Authored-By: Claude Opus 4.6 * Add exhaustive tests for PermissionCreateAgent permission Model tests: verify create_agent is in AllPermissions, has system scope, correct i18n fields, present in system_admin and system_user default roles, and absent from system_guest. Migration test: verify getAddCreateAgentPermissionMigration adds create_agent to both system_admin and system_user, and is idempotent on re-run. Also register the migration key in testlib mock store so server initialization skips it during test setup. Co-Authored-By: Claude Opus 4.6 * Add webapp permission constants and i18n for create_agent Add CREATE_AGENT constant to permissions.ts, display strings with defineMessages in permissions.tsx, and i18n entries in en.json so the permission appears in System Console Permission Schemes UI. Co-Authored-By: Claude Opus 4.6 * Clean up tests and minor fixups for create_agent permission Consolidate role_test.go into table-driven tests, remove redundant comments in permissions_migrations_test.go, add .planning/ to .gitignore, and refresh webapp/package-lock.json. Co-Authored-By: Claude Opus 4.6 (1M context) * Split create_agent into manage_own_agent and manage_others_agent Replace PermissionCreateAgent with system-scoped own/others permissions, update migration and defaults, and wire System Scheme UI for integrations. Made-with: Cursor * fixes * Stabilize autotranslation E2E by pinning mock source language Set LibreTranslate mock to English before the pre-enable post and Spanish before the post-enable message so parallel tests cannot leave the mock in a state where the new message is not translated. Made-with: Cursor * Revert package-lock, add more chnages * Revert "Revert package-lock, add more chnages" This reverts commit 7f6752c2e0a8bad39fd1a848abb8cf9f0bb611a3. * Drop unrelated autotranslation E2E tweak; restore package-lock The Playwright autotranslation change was not caused by MM-65671. Revert that test edit and restore webapp/package-lock.json after an accidental revert of the prior package-lock update. Made-with: Cursor * Put package-lock back again * fixes * Fix migration tests for manage_own_agent on system_user role Made-with: Cursor --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Mattermost Build --- .gitignore | 1 + server/channels/app/app_test.go | 2 + server/channels/app/permissions_migrations.go | 19 +++++++ .../app/permissions_migrations_test.go | 54 +++++++++++++++++++ server/channels/testlib/store.go | 1 + server/public/model/migration.go | 1 + server/public/model/permission.go | 17 ++++++ server/public/model/role.go | 1 + server/public/model/role_test.go | 43 +++++++++++++++ .../permissions_tree/permissions_tree.tsx | 10 ++++ .../strings/groups.tsx | 10 ++++ .../strings/permissions.tsx | 20 +++++++ webapp/channels/src/i18n/en.json | 6 +++ .../src/constants/permissions.ts | 2 + webapp/channels/src/utils/constants.tsx | 3 ++ 15 files changed, 190 insertions(+) diff --git a/.gitignore b/.gitignore index acae5a82e1a..ba3ceb93e7f 100644 --- a/.gitignore +++ b/.gitignore @@ -160,6 +160,7 @@ docker-compose.override.yaml .notice-work/ .aider* .env +.planning/ **/CLAUDE.local.md **/CLAUDE.md diff --git a/server/channels/app/app_test.go b/server/channels/app/app_test.go index 85de4883a8b..cfc1977a0f9 100644 --- a/server/channels/app/app_test.go +++ b/server/channels/app/app_test.go @@ -210,6 +210,7 @@ func TestDoAdvancedPermissionsMigration(t *testing.T) { model.PermissionDeleteCustomGroup.Id, model.PermissionRestoreCustomGroup.Id, model.PermissionManageCustomGroupMembers.Id, + model.PermissionManageOwnAgent.Id, }, "system_post_all": { model.PermissionCreatePost.Id, @@ -278,6 +279,7 @@ func TestDoEmojisPermissionsMigration(t *testing.T) { model.PermissionCreateEmojis.Id, model.PermissionDeleteEmojis.Id, model.PermissionViewMembers.Id, + model.PermissionManageOwnAgent.Id, } assert.ElementsMatch(t, expected3, role3.Permissions, fmt.Sprintf("'%v' did not have expected permissions", model.SystemUserRoleId)) diff --git a/server/channels/app/permissions_migrations.go b/server/channels/app/permissions_migrations.go index 49b519ddad5..5fb621089fe 100644 --- a/server/channels/app/permissions_migrations.go +++ b/server/channels/app/permissions_migrations.go @@ -1284,6 +1284,24 @@ func (a *App) getRestoreManageOAuthPermissionMigration() (permissionsMap, error) }, nil } +func (a *App) getAddManageAgentPermissionsMigration() (permissionsMap, error) { + return permissionsMap{ + permissionTransformation{ + On: isExactRole(model.SystemAdminRoleId), + Add: []string{ + model.PermissionManageOwnAgent.Id, + model.PermissionManageOthersAgent.Id, + }, + }, + permissionTransformation{ + On: isExactRole(model.SystemUserRoleId), + Add: []string{ + model.PermissionManageOwnAgent.Id, + }, + }, + }, nil +} + // DoPermissionsMigrations execute all the permissions migrations need by the current version. func (a *App) DoPermissionsMigrations() error { return a.Srv().doPermissionsMigrations() @@ -1343,6 +1361,7 @@ func (s *Server) doPermissionsMigrations() error { {Key: model.MigrationKeyAddChannelAutoTranslationPermissions, Migration: a.getAddChannelAutoTranslationPermissionMigration}, {Key: model.MigrationKeyAddSharedChannelManagerPermissions, Migration: a.getAddSharedChannelManagerPermissionsMigration}, {Key: model.MigrationKeyRestoreManageOAuthPermission, Migration: a.getRestoreManageOAuthPermissionMigration}, + {Key: model.MigrationKeyAddManageAgentPermissions, Migration: a.getAddManageAgentPermissionsMigration}, } roles, err := s.Store().Role().GetAll() diff --git a/server/channels/app/permissions_migrations_test.go b/server/channels/app/permissions_migrations_test.go index a7acdb8dda8..2ee21b23c50 100644 --- a/server/channels/app/permissions_migrations_test.go +++ b/server/channels/app/permissions_migrations_test.go @@ -65,6 +65,60 @@ func TestRestoreManageOAuthPermissionMigration(t *testing.T) { systemStore.AssertNumberOfCalls(t, "SaveOrUpdate", 1) } +func TestAddManageAgentPermissionsMigration(t *testing.T) { + mainHelper.Parallel(t) + + th := SetupWithStoreMock(t) + + migrationMap, err := th.App.getAddManageAgentPermissionsMigration() + require.NoError(t, err) + + systemAdminRole := &model.Role{ + Name: model.SystemAdminRoleId, + Permissions: []string{model.PermissionManageSystem.Id}, + } + systemUserRole := &model.Role{ + Name: model.SystemUserRoleId, + Permissions: []string{model.PermissionCreateDirectChannel.Id}, + } + roles := []*model.Role{systemAdminRole, systemUserRole} + + mockStore := th.App.Srv().Store().(*mocks.Store) + roleStore := mocks.RoleStore{} + systemStore := mocks.SystemStore{} + + mockStore.On("Role").Return(&roleStore) + mockStore.On("System").Return(&systemStore) + + systemStore.On("GetByName", model.MigrationKeyAddManageAgentPermissions). + Return(nil, model.NewAppError("test", "missing", nil, "", 404)).Once() + systemStore.On("GetByName", model.MigrationKeyAddManageAgentPermissions). + Return(&model.System{Name: model.MigrationKeyAddManageAgentPermissions, Value: "true"}, nil).Once() + systemStore.On("SaveOrUpdate", mock.MatchedBy(func(system *model.System) bool { + return system.Name == model.MigrationKeyAddManageAgentPermissions && system.Value == "true" + })).Return(nil).Once() + + roleStore.On("Save", mock.AnythingOfType("*model.Role")). + Return(func(role *model.Role) *model.Role { return role }, nil).Twice() + + appErr := th.App.Srv().doPermissionsMigration(model.MigrationKeyAddManageAgentPermissions, migrationMap, roles) + require.Nil(t, appErr) + assert.Contains(t, systemAdminRole.Permissions, model.PermissionManageOwnAgent.Id) + assert.Contains(t, systemAdminRole.Permissions, model.PermissionManageOthersAgent.Id) + assert.Contains(t, systemUserRole.Permissions, model.PermissionManageOwnAgent.Id) + assert.NotContains(t, systemUserRole.Permissions, model.PermissionManageOthersAgent.Id) + assert.Len(t, systemAdminRole.Permissions, 3) + assert.Len(t, systemUserRole.Permissions, 2) + + appErr = th.App.Srv().doPermissionsMigration(model.MigrationKeyAddManageAgentPermissions, migrationMap, roles) + require.Nil(t, appErr) + assert.Len(t, systemAdminRole.Permissions, 3, "system_admin should still have 3 permissions after idempotent run") + assert.Len(t, systemUserRole.Permissions, 2, "system_user should still have 2 permissions after idempotent run") + + roleStore.AssertNumberOfCalls(t, "Save", 2) + systemStore.AssertNumberOfCalls(t, "SaveOrUpdate", 1) +} + func TestApplyPermissionsMap(t *testing.T) { mainHelper.Parallel(t) tt := []struct { diff --git a/server/channels/testlib/store.go b/server/channels/testlib/store.go index a5d05aeec0f..52b36e0a1ab 100644 --- a/server/channels/testlib/store.go +++ b/server/channels/testlib/store.go @@ -96,6 +96,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { 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.MigrationKeyAccessControlPolicyV0_3).Return(&model.System{Name: model.MigrationKeyAccessControlPolicyV0_3, Value: "true"}, nil) + systemStore.On("GetByName", model.MigrationKeyAddManageAgentPermissions).Return(&model.System{Name: model.MigrationKeyAddManageAgentPermissions, Value: "true"}, nil) systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(&model.System{}, nil).Once() systemStore.On("Save", mock.AnythingOfType("*model.System")).Return(nil) diff --git a/server/public/model/migration.go b/server/public/model/migration.go index ffe3dd63cd4..61ee6b76ae9 100644 --- a/server/public/model/migration.go +++ b/server/public/model/migration.go @@ -61,4 +61,5 @@ const ( MigrationKeyAddSharedChannelManagerPermissions = "system_shared_channel_manager_permissions" MigrationKeyRestoreManageOAuthPermission = "restore_manage_oauth_permission" MigrationKeyAccessControlPolicyV0_3 = "access_control_policy_v0_3_migration" + MigrationKeyAddManageAgentPermissions = "add_manage_agent_permissions" ) diff --git a/server/public/model/permission.go b/server/public/model/permission.go index 1bbfee97e53..c46001797db 100644 --- a/server/public/model/permission.go +++ b/server/public/model/permission.go @@ -416,6 +416,8 @@ var SysconsoleReadPermissions []*Permission var SysconsoleWritePermissions []*Permission var PermissionManageOutgoingOAuthConnections *Permission +var PermissionManageOwnAgent *Permission +var PermissionManageOthersAgent *Permission var ModeratedBookmarkPermissions []*Permission func initializePermissions() { @@ -2328,6 +2330,19 @@ func initializePermissions() { PermissionScopeSystem, } + PermissionManageOwnAgent = &Permission{ + "manage_own_agent", + "authentication.permissions.manage_own_agent.name", + "authentication.permissions.manage_own_agent.description", + PermissionScopeSystem, + } + PermissionManageOthersAgent = &Permission{ + "manage_others_agent", + "authentication.permissions.manage_others_agent.name", + "authentication.permissions.manage_others_agent.description", + PermissionScopeSystem, + } + SysconsoleReadPermissions = []*Permission{ PermissionSysconsoleReadAboutEditionAndLicense, PermissionSysconsoleReadBilling, @@ -2524,6 +2539,8 @@ func initializePermissions() { PermissionManageLicenseInformation, PermissionCreateCustomGroup, PermissionManageOutgoingOAuthConnections, + PermissionManageOwnAgent, + PermissionManageOthersAgent, } TeamScopedPermissions := []*Permission{ diff --git a/server/public/model/role.go b/server/public/model/role.go index 18211c0d166..b1c8fe45d0d 100644 --- a/server/public/model/role.go +++ b/server/public/model/role.go @@ -1108,6 +1108,7 @@ func MakeDefaultRoles() map[string]*Role { PermissionDeleteCustomGroup.Id, PermissionRestoreCustomGroup.Id, PermissionManageCustomGroupMembers.Id, + PermissionManageOwnAgent.Id, }, SchemeManaged: true, BuiltIn: true, diff --git a/server/public/model/role_test.go b/server/public/model/role_test.go index 8dfaeb28f48..f7990e20507 100644 --- a/server/public/model/role_test.go +++ b/server/public/model/role_test.go @@ -341,3 +341,46 @@ func TestMakeDefaultRolesContainsNewManagerRoles(t *testing.T) { }), "manage_oauth should not remain deprecated") }) } + +func TestManageAgentPermissionsDefinition(t *testing.T) { + assert.Equal(t, "manage_own_agent", PermissionManageOwnAgent.Id) + assert.Equal(t, "authentication.permissions.manage_own_agent.name", PermissionManageOwnAgent.Name) + assert.Equal(t, "authentication.permissions.manage_own_agent.description", PermissionManageOwnAgent.Description) + assert.Equal(t, PermissionScopeSystem, PermissionManageOwnAgent.Scope, + "manage_own_agent should have system scope") + assert.True(t, slices.ContainsFunc(AllPermissions, func(p *Permission) bool { + return p.Id == PermissionManageOwnAgent.Id + }), "manage_own_agent should be in AllPermissions") + + assert.Equal(t, "manage_others_agent", PermissionManageOthersAgent.Id) + assert.Equal(t, "authentication.permissions.manage_others_agent.name", PermissionManageOthersAgent.Name) + assert.Equal(t, "authentication.permissions.manage_others_agent.description", PermissionManageOthersAgent.Description) + assert.Equal(t, PermissionScopeSystem, PermissionManageOthersAgent.Scope, + "manage_others_agent should have system scope") + assert.True(t, slices.ContainsFunc(AllPermissions, func(p *Permission) bool { + return p.Id == PermissionManageOthersAgent.Id + }), "manage_others_agent should be in AllPermissions") +} + +func TestManageAgentPermissionsDefaultRoles(t *testing.T) { + roles := MakeDefaultRoles() + + for _, tc := range []struct { + roleId string + expectOwn bool + expectOthers bool + }{ + {SystemAdminRoleId, true, true}, + {SystemUserRoleId, true, false}, + {SystemGuestRoleId, false, false}, + } { + t.Run(tc.roleId, func(t *testing.T) { + role, ok := roles[tc.roleId] + require.True(t, ok, "%s role should exist", tc.roleId) + assert.Equal(t, tc.expectOwn, slices.Contains(role.Permissions, PermissionManageOwnAgent.Id), + "%s manage_own_agent permission presence", tc.roleId) + assert.Equal(t, tc.expectOthers, slices.Contains(role.Permissions, PermissionManageOthersAgent.Id), + "%s manage_others_agent permission presence", tc.roleId) + }) + } +} diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx index 8589004c26c..f5d5838fb18 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx @@ -238,6 +238,16 @@ export default class PermissionsTree extends React.PureComponent { integrationsGroup.permissions.push(outgoingWebhookGroup); } } + const manageAgentsGroup = { + id: 'manage_agents_group', + permissions: [ + Permissions.MANAGE_OWN_AGENT, + Permissions.MANAGE_OTHERS_AGENT, + ], + }; + if (!integrationsGroup.permissions.some((p: any) => p.id === 'manage_agents_group')) { + integrationsGroup.permissions.push(manageAgentsGroup); + } if (config.EnableOAuthServiceProvider === 'true' && !integrationsGroup.permissions.includes(Permissions.MANAGE_OAUTH)) { integrationsGroup.permissions.push(Permissions.MANAGE_OAUTH); } diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/groups.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/groups.tsx index 48d91cf8dd4..eb3701df756 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/groups.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/groups.tsx @@ -125,6 +125,16 @@ export const groupRolesStrings: Record defaultMessage: 'Manage own and others\' outgoing webhooks.', }, }), + manage_agents_group: defineMessages({ + name: { + id: 'admin.permissions.group.manage_agents.name', + defaultMessage: 'Manage AI Agents', + }, + description: { + id: 'admin.permissions.group.manage_agents.description', + defaultMessage: 'Manage own and others\' AI agents.', + }, + }), manage_slash_commands_group: defineMessages({ name: { id: 'admin.permissions.group.manage_slash_commands.name', diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/permissions.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/permissions.tsx index e7dea308eb4..8ad1d59b5a8 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/permissions.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/permissions.tsx @@ -35,6 +35,26 @@ export const permissionRolesStrings: Record