Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8da272d
Bump server/public to v0.4.3 for plugin audit API
cursoragent Jul 28, 2026
ac29817
Add audit logging foundation: middleware, event registry, config refe…
cursoragent Jul 28, 2026
7c2c1bb
Audit enrichment for admin ops endpoints
cursoragent Jul 28, 2026
647c863
Audit enrichment for in-channel tool approval decisions
cursoragent Jul 28, 2026
e7d0f12
Audit MCP session grants for external clients
cursoragent Jul 28, 2026
2220edf
Emit audit record from defer so panicking handlers still produce a fa…
cursoragent Jul 28, 2026
f4694d3
Address wave-1 audit review: value-free answer errors, session grant …
cursoragent Jul 28, 2026
a512a84
Audit enrichment for agent CRUD endpoints
cursoragent Jul 28, 2026
ece36dc
Audit enrichment for custom prompt endpoints
cursoragent Jul 28, 2026
14230b6
Audit enrichment for MCP OAuth credential and user-preference endpoints
cursoragent Jul 28, 2026
b56aa95
Address wave-2 audit review: clamp free-text audit params, close test…
cursoragent Jul 28, 2026
fc9be1b
Audit enrichment for inter-plugin MCP registration endpoints
cursoragent Jul 28, 2026
86d41d0
Address section E review: clamp audit error descriptions, close bridg…
cursoragent Jul 28, 2026
550418a
Bump grpc, x/text, quic-go to patched versions flagged by security scan
cursoragent Jul 28, 2026
e8fcd97
Bump otel, x/net, edwards25519, compress to patched versions
cursoragent Jul 28, 2026
26381ed
Merge origin/master (remote MCP license gate) into audit-logging branch
cursoragent Jul 28, 2026
f9dde2f
Address PR review: shared ChangedJSONKeys helper, clamp all raw audit…
cursoragent Jul 28, 2026
b91ef5c
Keep audit records content-free structurally instead of degrading err…
cursoragent Jul 31, 2026
4d438b9
Add regression test: multipart parse errors cannot inject request tex…
cursoragent Aug 3, 2026
1cff973
docs: add audit logging rules to AGENTS.md
cursoragent Aug 5, 2026
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
13 changes: 10 additions & 3 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ type MCPClientManager interface {
DisconnectUserOAuth(userID, serverName string) error
MarkOAuthNeeded(userID, serverName, authURL string) error
GetEmbeddedServer() mcp.EmbeddedMCPServer
EnsureMCPSessionID(userID string) (string, error)
EnsureMCPSessionID(userID string) (sessionID string, created bool, err error)
GetToolsForUser(ctx context.Context, userID string) ([]llm.Tool, *mcp.Errors)
RefreshToolsForUser(ctx context.Context, userID string) ([]llm.Tool, *mcp.Errors, error)
GetConfig() mcp.Config
Expand Down Expand Up @@ -165,6 +165,10 @@ type API struct {
getSearchInitError func() string
customPromptsStore *customprompts.Store

// auditEvents maps gin handler names to audit event names for routes
// that emit server audit records. Built once in New; read-only after.
auditEvents map[string]string

// externalRebuilderForTest must be nil in production; SetExternalRebuilderForTest
// is the only supported entry point for tests.
externalRebuilderForTest externalServerRebuilder
Expand Down Expand Up @@ -206,7 +210,7 @@ func New(
getSearchInitError func() string,
customPromptsStore *customprompts.Store,
) *API {
return &API{
a := &API{
bots: bots,
conversationsService: conversationsService,
meetingsService: meetingsService,
Expand Down Expand Up @@ -239,6 +243,8 @@ func New(
getSearchInitError: getSearchInitError,
customPromptsStore: customPromptsStore,
}
a.auditEvents = buildAuditEventRegistry(a)
return a
}

// SetConversationService sets the conversation entity service for channel analysis.
Expand All @@ -252,6 +258,7 @@ func (a *API) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Reques
router.Use(otelgin.Middleware("mattermost-ai-agents"))
router.Use(a.ginlogger)
router.Use(a.metricsMiddleware)
router.Use(a.auditMiddleware(c))

// LLM Bridge API v1 routes - inter-plugin only
llmBridgeRoute := router.Group("/bridge/v1")
Expand All @@ -278,7 +285,7 @@ func (a *API) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Reques

// Store plugin.Context in gin.Context for MCP endpoints
mcpServerGroup.Use(func(gc *gin.Context) {
gc.Set("pluginContext", c)
gc.Set(pluginContextGinKey, c)
gc.Next()
})

Expand Down
26 changes: 26 additions & 0 deletions api/api_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/mattermost/mattermost-plugin-agents/v2/audit"
"github.com/mattermost/mattermost-plugin-agents/v2/indexer"
"github.com/mattermost/mattermost-plugin-agents/v2/mcp"
"github.com/mattermost/mattermost-plugin-agents/v2/mmapi"
Expand Down Expand Up @@ -45,6 +46,9 @@ func (a *API) handleReindexPosts(c *gin.Context) {
clearIndex = *req.ClearIndex
}

// Audit the effective value before starting so fail paths carry it too.
audit.AddParam(auditRec(c), "clear_index", clearIndex)

jobStatus, err := a.indexerService.StartReindexJob(clearIndex)
if err != nil {
switch err.Error() {
Expand Down Expand Up @@ -92,6 +96,7 @@ func (a *API) handleCancelJob(c *gin.Context) {
}

if a.indexerService == nil {
audit.AddParam(auditRec(c), "job_status", "no_job")
c.JSON(http.StatusNotFound, gin.H{
"status": "no_job",
})
Expand All @@ -101,13 +106,15 @@ func (a *API) handleCancelJob(c *gin.Context) {
jobStatus, err := a.indexerService.CancelJob()
if err != nil {
if mmapi.IsKVNotFound(err) {
audit.AddParam(auditRec(c), "job_status", "no_job")
c.JSON(http.StatusNotFound, gin.H{
"status": "no_job",
})
return
}
switch err.Error() {
case "not running":
audit.AddParam(auditRec(c), "job_status", "not_running")
c.JSON(http.StatusBadRequest, gin.H{
"status": "not_running",
})
Expand All @@ -118,6 +125,7 @@ func (a *API) handleCancelJob(c *gin.Context) {
}
}

audit.AddParam(auditRec(c), "job_status", jobStatus.Status)
c.JSON(http.StatusOK, jobStatus)
}

Expand All @@ -137,6 +145,8 @@ func (a *API) handleCatchUpIndex(c *gin.Context) {
if err != nil {
switch err.Error() {
case "job already running":
// The blocking job's status is the useful context on this fail path.
audit.AddParam(auditRec(c), "job_status", jobStatus.Status)
c.JSON(http.StatusConflict, jobStatus)
return
case "no previous index found, run a full reindex first":
Expand All @@ -148,6 +158,7 @@ func (a *API) handleCatchUpIndex(c *gin.Context) {
}
}

audit.AddParam(auditRec(c), "job_status", jobStatus.Status)
c.JSON(http.StatusOK, jobStatus)
}

Expand Down Expand Up @@ -407,6 +418,8 @@ func (a *API) handleClearMCPToolsCache(c *gin.Context) {
return
}

audit.AddParam(auditRec(c), "cleared_servers", clearedCount)

c.JSON(http.StatusOK, ClearMCPToolsCacheResponse{
ClearedServers: clearedCount,
Message: fmt.Sprintf("Successfully cleared cache for %d servers", clearedCount),
Expand Down Expand Up @@ -449,12 +462,18 @@ func (a *API) handleUpdatePluginServer(c *gin.Context) {
return
}

// Identify the target plugin as early as possible so fail paths carry it.
audit.AddParam(auditRec(c), audit.KeyMCPPluginID, audit.TruncateID(pluginID))

var req UpdatePluginServerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
return
}

// Whether tool policy was touched — never the configs themselves.
audit.AddParam(auditRec(c), "tool_configs_changed", req.ToolConfigs != nil)

live, foundLive := a.mcpClientManager.GetPluginServer(pluginID)
if !foundLive {
c.AbortWithError(http.StatusNotFound, fmt.Errorf("plugin MCP server %q is not registered", pluginID))
Expand All @@ -469,6 +488,9 @@ func (a *API) handleUpdatePluginServer(c *gin.Context) {
updated.ToolConfigs = *req.ToolConfigs
}

// Effective final value after partial-update merge, not the raw request.
audit.AddParam(auditRec(c), "enabled", updated.Enabled)

existing, getErr := a.configStore.GetConfig()
if getErr != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to load config for plugin-server save: %w", getErr))
Expand Down Expand Up @@ -504,6 +526,10 @@ func (a *API) handleUpdatePluginServer(c *gin.Context) {
return
}

// The mutation is persisted from here on; a later cluster-notify failure
// yields a fail record for a change that actually landed — say so.
audit.AddParam(auditRec(c), "persisted", true)

if err := a.clusterNotifier.PublishConfigUpdate(); err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to notify cluster of plugin-server config update: %w", err))
return
Expand Down
Loading
Loading