Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,10 @@ type AIBotInfo struct {
UserIDs []string `json:"userIDs"`
EnabledMCPTools []llm.EnabledMCPTool `json:"enabledMCPTools"`
AutoEnableNewMCPTools bool `json:"autoEnableNewMCPTools"`
IsDefault bool `json:"isDefault,omitempty"`
// UseServiceAccountAuth is the effective mode, not the raw agent flag: it is
// false when the server is not licensed for remote MCP.
UseServiceAccountAuth bool `json:"useServiceAccountAuth"`
IsDefault bool `json:"isDefault,omitempty"`
}

type AIBotsResponse struct {
Expand All @@ -518,6 +521,16 @@ type AIBotsResponse struct {
AllowUnsafeLinks bool `json:"allowUnsafeLinks"`
}

// usesServiceAccountAuth reports the effective service account mode for a bot:
// on an unlicensed server a service account agent runs in per-user mode, and the
// webapp keys its per-user MCP UI off this value.
func (a *API) usesServiceAccountAuth(bot *bots.Bot) bool {
if a.contextBuilder == nil {
return bot.GetConfig().UseServiceAccountAuth
}
return a.contextBuilder.UsesServiceAccountCatalog(bot)
}

// getAIBotsForUser returns all AI bots available to a user
func (a *API) getAIBotsForUser(userID string) ([]AIBotInfo, error) {
allBots := a.bots.GetAllBots()
Expand Down Expand Up @@ -554,6 +567,7 @@ func (a *API) getAIBotsForUser(userID string) ([]AIBotInfo, error) {
UserIDs: bot.GetConfig().UserIDs,
EnabledMCPTools: bot.GetConfig().EnabledMCPTools,
AutoEnableNewMCPTools: bot.GetConfig().AutoEnableNewMCPTools,
UseServiceAccountAuth: a.usesServiceAccountAuth(bot),
IsDefault: isDefault,
})
if isDefault {
Expand Down
27 changes: 27 additions & 0 deletions api/api_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import (

var validUsernameRe = regexp.MustCompile(`^[a-z][a-z0-9._-]*$`)

// errServiceAccountAuthRequiresAdmin is returned when a caller without
// PermissionManageSystem tries to turn the service account flag on.
var errServiceAccountAuthRequiresAdmin = errors.New("only system administrators can enable service account authentication for an agent")

// WebsocketEventBotsInvalidate is the event name for PublishWebSocketEvent (webapp: custom_mattermost-ai_<name>).
const WebsocketEventBotsInvalidate = "bots_invalidate"

Expand Down Expand Up @@ -72,6 +76,7 @@ type CreateAgentRequest struct {
EnabledMCPTools []llm.EnabledMCPTool `json:"enabledMCPTools"`
AutoEnableNewMCPTools bool `json:"autoEnableNewMCPTools"`
MCPDynamicToolLoading bool `json:"mcpDynamicToolLoading"`
UseServiceAccountAuth bool `json:"useServiceAccountAuth"`
Model string `json:"model"`
EnableVision bool `json:"enableVision"`
DisableTools bool `json:"disableTools"`
Expand Down Expand Up @@ -99,6 +104,7 @@ type UpdateAgentRequest struct {
EnabledMCPTools []llm.EnabledMCPTool `json:"enabledMCPTools"`
AutoEnableNewMCPTools bool `json:"autoEnableNewMCPTools"`
MCPDynamicToolLoading bool `json:"mcpDynamicToolLoading"`
UseServiceAccountAuth bool `json:"useServiceAccountAuth"`
Model string `json:"model"`
EnableVision bool `json:"enableVision"`
DisableTools bool `json:"disableTools"`
Expand Down Expand Up @@ -193,6 +199,13 @@ func canCreateAgent(client *pluginapi.Client, userID string) bool {
return client.User.HasPermissionTo(userID, model.PermissionManageSystem)
}

// canEnableServiceAccountAuth reports whether userID may turn the service account
// flag on. It grants the agent the admin-provisioned MCP credentials, so enabling
// it is restricted to system admins even when the caller can manage the agent.
func canEnableServiceAccountAuth(client *pluginapi.Client, userID string) bool {
return client.User.HasPermissionTo(userID, model.PermissionManageSystem)
}

// canConfigureAgentServices reports whether userID may list services or fetch models (ManageOwnAgent, ManageOthersAgent, or ManageSystem).
func canConfigureAgentServices(client *pluginapi.Client, userID string) bool {
if client.User.HasPermissionTo(userID, model.PermissionManageOwnAgent) {
Expand Down Expand Up @@ -257,6 +270,7 @@ func buildAgentConfigForCreate(req CreateAgentRequest, userID, botUserID string)
EnabledMCPTools: req.EnabledMCPTools,
AutoEnableNewMCPTools: req.AutoEnableNewMCPTools,
MCPDynamicToolLoading: req.MCPDynamicToolLoading,
UseServiceAccountAuth: req.UseServiceAccountAuth,
Model: req.Model,
EnableVision: req.EnableVision,
DisableTools: req.DisableTools,
Expand Down Expand Up @@ -284,6 +298,7 @@ func applyAgentUpdateRequest(cfg *llm.BotConfig, req UpdateAgentRequest) (displa
cfg.EnabledMCPTools = req.EnabledMCPTools
cfg.AutoEnableNewMCPTools = req.AutoEnableNewMCPTools
cfg.MCPDynamicToolLoading = req.MCPDynamicToolLoading
cfg.UseServiceAccountAuth = req.UseServiceAccountAuth
cfg.Model = req.Model
cfg.EnableVision = req.EnableVision
cfg.DisableTools = req.DisableTools
Expand Down Expand Up @@ -346,6 +361,11 @@ func (a *API) handleCreateAgent(c *gin.Context) {
return
}

if req.UseServiceAccountAuth && !canEnableServiceAccountAuth(a.pluginAPI, userID) {
abortAgentRequest(c, http.StatusForbidden, errServiceAccountAuthRequiresAdmin)
return
}

if !validUsernameRe.MatchString(req.Username) {
abortAgentRequest(c, http.StatusBadRequest, errors.New("invalid username: must start with a lowercase letter and contain only lowercase letters, numbers, dots, hyphens, or underscores"))
return
Expand Down Expand Up @@ -479,6 +499,13 @@ func (a *API) handleUpdateAgent(c *gin.Context) {
return
}

// Only the false→true transition escalates; an already-enabled flag was
// admin-granted, so keeping or clearing it needs no extra permission.
if req.UseServiceAccountAuth && !cfg.UseServiceAccountAuth && !canEnableServiceAccountAuth(a.pluginAPI, userID) {
abortAgentRequest(c, http.StatusForbidden, errServiceAccountAuthRequiresAdmin)
return
}

if req.usernameProvided && req.Username != cfg.Name {
abortAgentRequest(c, http.StatusBadRequest, errors.New("username cannot be changed after the agent is created"))
return
Expand Down
130 changes: 129 additions & 1 deletion api/api_agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ func updateAgentBodyFromStored(cfg *llm.BotConfig, overrides map[string]any) map
"enabledMCPTools": cfg.EnabledMCPTools,
"autoEnableNewMCPTools": cfg.AutoEnableNewMCPTools,
"mcpDynamicToolLoading": cfg.MCPDynamicToolLoading,
"useServiceAccountAuth": cfg.UseServiceAccountAuth,
"model": cfg.Model,
"enableVision": cfg.EnableVision,
"disableTools": cfg.DisableTools,
Expand Down Expand Up @@ -204,6 +205,8 @@ func TestCreateAgentPersistsExplicitRequestValues(t *testing.T) {

mockLicensed(e.mockAPI)
e.mockAPI.On("HasPermissionTo", testUserID, model.PermissionManageOwnAgent).Return(true)
// Enabling service account auth is system-admin only.
e.mockAPI.On("HasPermissionTo", testUserID, model.PermissionManageSystem).Return(true)
e.mockAPI.On("CreateBot", mock.AnythingOfType("*model.Bot")).Return(&model.Bot{
UserId: "bot-user-id-created",
Username: "my-agent",
Expand All @@ -221,6 +224,7 @@ func TestCreateAgentPersistsExplicitRequestValues(t *testing.T) {
"reasoningEnabled": false,
"reasoningEffort": "high",
"structuredOutputEnabled": false,
"useServiceAccountAuth": true,
})

recorder := doRequest(e.api, http.MethodPost, "/agents", body, testUserID)
Expand All @@ -234,6 +238,8 @@ func TestCreateAgentPersistsExplicitRequestValues(t *testing.T) {
assert.Equal(t, "high", agent.ReasoningEffort)
assert.False(t, agent.StructuredOutputEnabled)
assert.Empty(t, agent.EnabledNativeTools)
assert.True(t, agent.UseServiceAccountAuth)
assert.True(t, e.agentStore.agents[agent.ID].UseServiceAccountAuth)
}

func TestCreateAgentMaxToolTurnsRoundTrip(t *testing.T) {
Expand Down Expand Up @@ -590,10 +596,14 @@ func TestUpdateAgentAsCreator(t *testing.T) {
stored := &llm.BotConfig{
ID: "agent-1", CreatorID: testUserID, BotUserID: "bot-1",
DisplayName: "Original", Name: "original", ServiceID: "svc-1",
UseServiceAccountAuth: true,
}
e.agentStore.agents["agent-1"] = stored

body := updateAgentBodyFromStored(stored, map[string]any{"displayName": "Updated"})
body := updateAgentBodyFromStored(stored, map[string]any{
"displayName": "Updated",
"useServiceAccountAuth": false,
})

// Mock bot patch for display name sync
e.mockAPI.On("PatchBot", "bot-1", mock.AnythingOfType("*model.BotPatch")).Return(&model.Bot{}, nil).Maybe()
Expand All @@ -604,6 +614,8 @@ func TestUpdateAgentAsCreator(t *testing.T) {
var agent llm.BotConfig
require.NoError(t, json.NewDecoder(recorder.Result().Body).Decode(&agent))
assert.Equal(t, "Updated", agent.DisplayName)
assert.False(t, agent.UseServiceAccountAuth)
assert.False(t, e.agentStore.agents["agent-1"].UseServiceAccountAuth)
}

func TestUpdateAgentAsAdminUser(t *testing.T) {
Expand Down Expand Up @@ -671,6 +683,122 @@ func TestUpdateAgentOwnedByOtherWithManageOthersPermission(t *testing.T) {
assert.Equal(t, "Admin Renamed", agent.DisplayName)
}

// Service account auth hands the agent the admin-provisioned MCP credentials, so
// only system admins may switch it on; anyone who can manage the agent may keep an
// already-granted flag or clear it.
func TestAgentServiceAccountAuthRequiresSystemAdmin(t *testing.T) {
tests := []struct {
name string
create bool
systemAdmin bool
storedValue bool
requestValue bool
expectedStatus int
expectStored bool
}{
{
name: "non-admin cannot create with service account auth",
create: true,
requestValue: true,
expectedStatus: http.StatusForbidden,
},
{
name: "admin can create with service account auth",
create: true,
systemAdmin: true,
requestValue: true,
expectedStatus: http.StatusCreated,
expectStored: true,
},
{
name: "non-admin cannot turn service account auth on",
requestValue: true,
expectedStatus: http.StatusForbidden,
},
{
name: "admin can turn service account auth on",
systemAdmin: true,
requestValue: true,
expectedStatus: http.StatusOK,
expectStored: true,
},
{
name: "non-admin can update an agent that already uses service account auth",
storedValue: true,
requestValue: true,
expectedStatus: http.StatusOK,
expectStored: true,
},
{
name: "non-admin can turn service account auth off",
storedValue: true,
expectedStatus: http.StatusOK,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
e := setupAgentTestEnvironment(t)
defer e.Cleanup(t)

mockLicensed(e.mockAPI)
e.mockAPI.On("HasPermissionTo", testUserID, model.PermissionManageOwnAgent).Return(true).Maybe()
e.mockAPI.On("HasPermissionTo", testUserID, model.PermissionManageSystem).Return(tc.systemAdmin).Maybe()
e.mockAPI.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return().Maybe()

if tc.create {
e.mockAPI.On("CreateBot", mock.AnythingOfType("*model.Bot")).Return(&model.Bot{
UserId: "bot-user-id-created",
Username: "my-agent",
DisplayName: "My Agent",
}, nil).Maybe()

body := createAgentBody(map[string]any{"useServiceAccountAuth": tc.requestValue})
recorder := doRequest(e.api, http.MethodPost, "/agents", body, testUserID)
require.Equal(t, tc.expectedStatus, recorder.Result().StatusCode)

if tc.expectedStatus != http.StatusCreated {
assert.Contains(t, decodeAgentError(t, recorder), "system administrators")
assert.Empty(t, e.agentStore.agents)
return
}

var agent llm.BotConfig
require.NoError(t, json.NewDecoder(recorder.Body).Decode(&agent))
assert.Equal(t, tc.expectStored, e.agentStore.agents[agent.ID].UseServiceAccountAuth)
return
}

stored := &llm.BotConfig{
ID: "agent-1", CreatorID: testUserID, BotUserID: "bot-1",
DisplayName: "Original", Name: "original", ServiceID: "svc-1",
UseServiceAccountAuth: tc.storedValue,
}
e.agentStore.agents["agent-1"] = stored
e.mockAPI.On("PatchBot", "bot-1", mock.AnythingOfType("*model.BotPatch")).Return(&model.Bot{}, nil).Maybe()

body := updateAgentBodyFromStored(stored, map[string]any{
"displayName": "Updated",
"useServiceAccountAuth": tc.requestValue,
})
recorder := doRequest(e.api, http.MethodPut, "/agents/agent-1", body, testUserID)
require.Equal(t, tc.expectedStatus, recorder.Result().StatusCode)
if tc.expectedStatus == http.StatusForbidden {
assert.Contains(t, decodeAgentError(t, recorder), "system administrators")
}
assert.Equal(t, tc.expectStored, e.agentStore.agents["agent-1"].UseServiceAccountAuth)
})
}
}

// decodeAgentError returns the message from a JSON agent error response body.
func decodeAgentError(t *testing.T, recorder *httptest.ResponseRecorder) string {
t.Helper()
var payload agentErrorResponse
require.NoError(t, json.NewDecoder(recorder.Body).Decode(&payload))
return payload.Error
}

func TestDeleteAgentDeactivatesBot(t *testing.T) {
e := setupAgentTestEnvironment(t)
defer e.Cleanup(t)
Expand Down
4 changes: 4 additions & 0 deletions api/api_channel_analysis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ func (p *channelAnalysisMCPProvider) GetToolsForUser(context.Context, string) ([
return p.tools, nil
}

func (p *channelAnalysisMCPProvider) GetToolsForServiceAccount(context.Context, string) ([]llm.Tool, *mcp.Errors) {
return nil, nil
}

type channelAnalysisSequenceLLM struct {
calls [][]llm.TextStreamEvent
requests []llm.CompletionRequest
Expand Down
14 changes: 14 additions & 0 deletions api/api_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,17 @@ func TestSaveAndGetConfigRoundTrip(t *testing.T) {
Bots: []llm.BotConfig{
{ID: "bot-1", Name: "ai", ServiceID: "svc-1"},
},
MCP: config.MCPConfig{
Servers: []config.MCPServerConfig{
{
Name: "Jira",
Enabled: true,
BaseURL: "https://jira.example.com",
Headers: map[string]string{"X-Trace": "on"},
ServiceAccountHeaders: map[string]string{"Authorization": "Bearer service-pat"},
},
},
},
}
body, err := json.Marshal(saveCfg)
require.NoError(t, err)
Expand Down Expand Up @@ -404,6 +415,9 @@ func TestSaveAndGetConfigRoundTrip(t *testing.T) {
assert.Equal(t, "bot-1", loadedCfg.Bots[0].ID)
assert.True(t, loadedCfg.MCP.Enabled)
assert.True(t, loadedCfg.MCP.EmbeddedServer.Enabled)
require.Len(t, loadedCfg.MCP.Servers, 1)
assert.Equal(t, map[string]string{"X-Trace": "on"}, loadedCfg.MCP.Servers[0].Headers)
assert.Equal(t, map[string]string{"Authorization": "Bearer service-pat"}, loadedCfg.MCP.Servers[0].ServiceAccountHeaders)

// Step 4: Verify side effects
assert.Equal(t, 1, updater.callCount)
Expand Down
10 changes: 5 additions & 5 deletions api/api_llm_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,7 @@ func (a *API) buildLLMBridgeContext(bot *bots.Bot, req bridgeclient.CompletionRe
} else {
context = llm.NewContext()
if bot != nil {
var botUserID string
if mmBot := bot.GetMMBot(); mmBot != nil {
botUserID = mmBot.UserId
}
context.SetBotFields(bot.GetConfig().DisplayName, bot.GetConfig().Name, botUserID, bot.GetService().DefaultModel, bot.GetService().Type, bot.GetConfig().CustomInstructions)
context.SetBotFields(bot.GetConfig().DisplayName, bot.GetConfig().Name, bot.BotUserID(), bot.GetService().DefaultModel, bot.GetService().Type, bot.GetConfig().CustomInstructions)
}
}

Expand Down Expand Up @@ -171,6 +167,10 @@ func (a *API) convertAgentBridgeRequestToInternal(ctx stdcontext.Context, bot *b
}

bridgeContext := llm.NewContext()
if a.contextBuilder != nil {
// Populate bot identity for token-usage attribution and embedded MCP metadata.
a.contextBuilder.WithLLMContextBot(bot)(bridgeContext)
}
bridgeContext.RequestingUser = &model.User{Id: req.UserID}
if includeTools && a.contextBuilder != nil {
a.contextBuilder.WithLLMContextConcreteTools(ctx, bot)(bridgeContext)
Expand Down
Loading
Loading