Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
329ca37
feat: audit MCP server config changes
ibetitsmike Aug 7, 2026
f7d4945
chore: polish CODAGT-717 comments per cleanup gate
ibetitsmike Aug 7, 2026
9682815
test(coderd): audit denied MCP server config deletions
ibetitsmike Aug 8, 2026
a72d5cf
fix(enterprise/audit): redact MCP server endpoint URLs in audit diffs
ibetitsmike Aug 8, 2026
0a32b24
fix(coderd): mark deleted MCP server configs in audit logs
ibetitsmike Aug 8, 2026
08a7219
fix(coderd): make MCP config audit records match persisted rows
ibetitsmike Aug 8, 2026
09fb6fb
fix(coderd): audit MCP config deletions against the locked row
ibetitsmike Aug 10, 2026
86d42a7
chore(coderd/database/migrations): renumber migration 000566 to 000569
ibetitsmike Aug 11, 2026
197a759
chore(coderd/database/migrations): renumber migration 000569 to 000570
ibetitsmike Aug 11, 2026
a04fd6f
fix(coderd): address MCP audit review feedback
ibetitsmike Aug 11, 2026
6316be4
fix(enterprise/audit): track MCP server endpoint URLs in audit diffs
ibetitsmike Aug 13, 2026
a848d84
fix(coderd/database): renumber MCP audit migration to 000571
ibetitsmike Aug 13, 2026
db41667
chore: shorten the audit layer reread comment per cleanup gate
ibetitsmike Aug 13, 2026
a0a84be
fix(coderd): drop whitespace-only MCP display name rejection
ibetitsmike Aug 17, 2026
7dfb53e
test(coderd): pass the organization to MCP config SDK calls
ibetitsmike Aug 17, 2026
9eed1a3
fix(coderd/database): renumber MCP audit migration to 000572
ibetitsmike Aug 17, 2026
a3af59d
fix(coderd/audit): fall back to the slug for empty MCP display names
ibetitsmike Aug 17, 2026
bb776b8
test(coderd): audit MCP config creation only when a row persists
ibetitsmike Aug 17, 2026
e39972f
fix(coderd/audit): retain identifiable MCP audit targets
ibetitsmike Aug 18, 2026
d3d9854
fix(enterprise/audit): canonicalize empty MCP fields
ibetitsmike Aug 18, 2026
f48ed8f
test(enterprise/audit): cover MCP header transitions
ibetitsmike Aug 18, 2026
146d8c8
fix(coderd): gate MCP audit links on management access
ibetitsmike Aug 18, 2026
a03f5ff
fix(coderd): match MCP audit links to settings page access
ibetitsmike Aug 18, 2026
b0ffee0
fix(coderd/database): renumber MCP audit migration to 000575
ibetitsmike Aug 18, 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
Next Next commit
feat: audit MCP server config changes
Create, update, and delete of MCP server configs now produce
enterprise audit log entries with field diffs. Secret material
(oauth2_client_secret, api_key_value, custom_headers) renders as a
redacted change marker and never appears in the diff; dbcrypt key-id
bookkeeping columns are excluded entirely.

Write-denied mutations on a readable config record an audited 403.
Read-denied callers stay concealed with 404 by the param middleware
and record nothing.
  • Loading branch information
ibetitsmike committed Aug 19, 2026
commit 329ca37e28bc165b37c09bc5c8c5996f241eba76
2 changes: 2 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/audit/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type Auditable interface {
database.AIProviderKey |
database.AIGatewayKey |
database.Chat |
database.MCPServerConfig |
database.AuditableGroupAIBudget |
database.AuditableUserAIBudgetOverride |
database.UserSecret |
Expand Down
15 changes: 15 additions & 0 deletions coderd/audit/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ func ResourceTarget[T Auditable](tgt T) string {
// for display; collisions affect the display label and search
// filter but not the primary resource identifier.
return typed.ID.String()[:8]
case database.MCPServerConfig:
if typed.DisplayName != "" {
return typed.DisplayName
}
// Configs without a display name fall back to the full ID, which
// equals resource_id, so the shown value is exactly filterable.
return typed.ID.String()
case database.UserSecret:
return typed.Name
case database.UserSkill:
Expand Down Expand Up @@ -257,6 +264,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.UserID
case database.Chat:
return typed.ID
case database.MCPServerConfig:
return typed.ID
case database.UserSecret:
return typed.ID
case database.UserSkill:
Expand Down Expand Up @@ -335,6 +344,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeUserAIBudgetOverride
case database.Chat:
return database.ResourceTypeChat
case database.MCPServerConfig:
return database.ResourceTypeMCPServerConfig
Comment thread
ibetitsmike marked this conversation as resolved.
case database.UserSecret:
return database.ResourceTypeUserSecret
case database.UserSkill:
Expand Down Expand Up @@ -425,6 +436,10 @@ func ResourceRequiresOrgID[T Auditable]() bool {
// Chats always have a non-null organization_id (since
// migration 000467).
return true
case database.MCPServerConfig:
// MCP server configs are org-scoped; every row carries a
// non-null organization_id.
return true
case database.UserSecret:
// User secrets are global to the user across organizations.
return false
Expand Down
3 changes: 2 additions & 1 deletion coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- No-op, enum values can't be dropped.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TYPE resource_type
ADD VALUE IF NOT EXISTS 'mcp_server_config';
5 changes: 4 additions & 1 deletion coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/database/sqlc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ sql:
resource_type_ai_gateway_key: ResourceTypeAIGatewayKey
mcp_server_config: MCPServerConfig
mcp_server_configs: MCPServerConfigs
resource_type_mcp_server_config: ResourceTypeMCPServerConfig
mcp_server_user_token: MCPServerUserToken
mcp_server_user_tokens: MCPServerUserTokens
mcp_server_tool_snapshot: MCPServerToolSnapshot
Expand Down
44 changes: 44 additions & 0 deletions coderd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"golang.org/x/xerrors"

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
Expand Down Expand Up @@ -248,6 +249,15 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
organization := httpmw.OrganizationParam(r)
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
OrganizationID: organization.ID,
})
defer commitAudit()
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceMCPServerConfig.InOrg(organization.ID)) {
httpapi.Forbidden(rw)
return
Expand Down Expand Up @@ -441,6 +451,8 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
}
}

aReq.New = inserted

httpapi.Write(ctx, rw, http.StatusCreated, convertMCPServerConfig(inserted))
}

Expand Down Expand Up @@ -544,6 +556,21 @@ func (api *API) getMCPServerConfigForMutation(rw http.ResponseWriter, r *http.Re
func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit()

// Set Old before the write-authz check so a write-denied 403 is
// audited. Read-denied callers were already concealed with 404 by
// the param middleware and never reach this handler.
Comment thread
ibetitsmike marked this conversation as resolved.
aReq.Old = httpmw.MCPServerConfigParam(r)
Comment thread
ibetitsmike marked this conversation as resolved.
aReq.UpdateOrganizationID(aReq.Old.OrganizationID)

existing, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionUpdate)
if !ok {
return
Expand Down Expand Up @@ -873,6 +900,8 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
}
}

aReq.New = updated

httpapi.Write(ctx, rw, http.StatusOK, convertMCPServerConfig(updated))
}

Expand All @@ -888,6 +917,21 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) deleteMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.MCPServerConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
})
defer commitAudit()

// Set Old before the write-authz check so a write-denied 403 is
// audited. Read-denied callers were already concealed with 404 by
// the param middleware and never reach this handler.
Comment thread
ibetitsmike marked this conversation as resolved.
aReq.Old = httpmw.MCPServerConfigParam(r)
Comment thread
ibetitsmike marked this conversation as resolved.
aReq.UpdateOrganizationID(aReq.Old.OrganizationID)

config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionDelete)
if !ok {
return
Expand Down
131 changes: 131 additions & 0 deletions coderd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
Expand Down Expand Up @@ -261,6 +262,136 @@ func TestMCPServerConfigWrongOrganization(t *testing.T) {
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
}

func TestMCPServerConfigsAudit(t *testing.T) {
t.Parallel()

newAuditedMCPClient := func(t testing.TB) (*codersdk.Client, *audit.MockAuditor) {
t.Helper()
mAudit := audit.NewMock()
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: mcpDeploymentValues(t),
ChatProviderAPIKeys: &providerKeys,
Auditor: mAudit,
})
return client, mAudit
}

t.Run("Create", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)

mAudit.ResetLogs()
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
DisplayName: "Audit Create",
Slug: "audit-create",
Transport: "streamable_http",
URL: "https://mcp.example.com/audit",
AuthType: "api_key",
APIKeyHeader: "X-Api-Key",
APIKeyValue: "super-secret-api-key",
CustomHeaders: map[string]string{
"X-Extra": "plaintext-header-value",
},
Availability: "default_on",
Enabled: true,
})
require.NoError(t, err)

logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionCreate, logs[0].Action)
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
require.Equal(t, created.ID, logs[0].ResourceID)
require.Equal(t, "Audit Create", logs[0].ResourceTarget)
require.Equal(t, firstUser.UserID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusCreated, logs[0].StatusCode)
})

t.Run("Update", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-update", true)

mAudit.ResetLogs()
newName := "Audit Update"
updated, err := client.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
DisplayName: &newName,
})
require.NoError(t, err)
require.Equal(t, newName, updated.DisplayName)

logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
require.Equal(t, config.ID, logs[0].ResourceID)
require.Equal(t, newName, logs[0].ResourceTarget)
require.Equal(t, firstUser.UserID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusOK, logs[0].StatusCode)
})

t.Run("Delete", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete", true)

mAudit.ResetLogs()
err := client.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID)
require.NoError(t, err)

logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionDelete, logs[0].Action)
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
require.Equal(t, config.ID, logs[0].ResourceID)
require.Equal(t, firstUser.UserID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
})

// A read-allowed but write-denied caller reaches the handler, so the
// denied mutation is audited with a 403 status.
t.Run("WriteDeniedAudited", func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitLong)
client, mAudit := newAuditedMCPClient(t)
firstUser := coderdtest.CreateFirstUser(t, client)
memberClient, member := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-denied", true)

mAudit.ResetLogs()
newName := "denied"
_, err := memberClient.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
DisplayName: &newName,
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())

logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
require.Equal(t, config.ID, logs[0].ResourceID)
require.Equal(t, member.ID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusForbidden, logs[0].StatusCode)
})
}

func TestMCPServerConfigsNonAdmin(t *testing.T) {
t.Parallel()

Expand Down
3 changes: 3 additions & 0 deletions codersdk/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const (
ResourceTypeGroupAIBudget ResourceType = "group_ai_budget"
ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override"
ResourceTypeChat ResourceType = "chat"
ResourceTypeMCPServerConfig ResourceType = "mcp_server_config"
ResourceTypeUserSecret ResourceType = "user_secret"
ResourceTypeUserSkill ResourceType = "user_skill"
ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings"
Expand Down Expand Up @@ -130,6 +131,8 @@ func (r ResourceType) FriendlyString() string {
return "user ai budget override"
case ResourceTypeChat:
return "chat"
case ResourceTypeMCPServerConfig:
return "mcp server config"
case ResourceTypeUserSecret:
return "user secret"
case ResourceTypeUserSkill:
Expand Down
Loading