From 329ca37e28bc165b37c09bc5c8c5996f241eba76 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:19:06 +0000 Subject: [PATCH 01/24] 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. --- coderd/apidoc/docs.go | 2 + coderd/apidoc/swagger.json | 2 + coderd/audit/diff.go | 1 + coderd/audit/request.go | 15 ++ coderd/database/dump.sql | 3 +- .../000566_audit_mcp_server_config.down.sql | 1 + .../000566_audit_mcp_server_config.up.sql | 2 + coderd/database/models.go | 5 +- coderd/database/sqlc.yaml | 1 + coderd/mcp.go | 44 ++++++ coderd/mcp_test.go | 131 ++++++++++++++++++ codersdk/audit.go | 3 + docs/admin/security/audit-logs.md | 1 + docs/reference/api/schemas.md | 2 +- enterprise/audit/diff_internal_test.go | 95 +++++++++++++ enterprise/audit/table.go | 35 +++++ enterprise/coderd/mcp_test.go | 15 ++ site/src/api/typesGenerated.ts | 2 + 18 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 coderd/database/migrations/000566_audit_mcp_server_config.down.sql create mode 100644 coderd/database/migrations/000566_audit_mcp_server_config.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bcdf54ed3ad..a801739c412 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -24939,6 +24939,7 @@ const docTemplate = `{ "group_ai_budget", "user_ai_budget_override", "chat", + "mcp_server_config", "user_secret", "user_skill", "chat_instruction_settings" @@ -24978,6 +24979,7 @@ const docTemplate = `{ "ResourceTypeGroupAIBudget", "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", + "ResourceTypeMCPServerConfig", "ResourceTypeUserSecret", "ResourceTypeUserSkill", "ResourceTypeChatInstructionSettings" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a05523cc8d5..5962b014af4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -22876,6 +22876,7 @@ "group_ai_budget", "user_ai_budget_override", "chat", + "mcp_server_config", "user_secret", "user_skill", "chat_instruction_settings" @@ -22915,6 +22916,7 @@ "ResourceTypeGroupAIBudget", "ResourceTypeUserAIBudgetOverride", "ResourceTypeChat", + "ResourceTypeMCPServerConfig", "ResourceTypeUserSecret", "ResourceTypeUserSkill", "ResourceTypeChatInstructionSettings" diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 97105c24d54..8564f33a964 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -39,6 +39,7 @@ type Auditable interface { database.AIProviderKey | database.AIGatewayKey | database.Chat | + database.MCPServerConfig | database.AuditableGroupAIBudget | database.AuditableUserAIBudgetOverride | database.UserSecret | diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 9aaafb1e2cc..d09b2fe0606 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -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: @@ -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: @@ -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 case database.UserSecret: return database.ResourceTypeUserSecret case database.UserSkill: @@ -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 diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 63487a1a9a0..0512d79845c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -604,7 +604,8 @@ CREATE TYPE resource_type AS ENUM ( 'ai_gateway_key', 'user_ai_budget_override', 'oauth2_provider_settings', - 'chat_instruction_settings' + 'chat_instruction_settings', + 'mcp_server_config' ); CREATE TYPE shareable_workspace_owners AS ENUM ( diff --git a/coderd/database/migrations/000566_audit_mcp_server_config.down.sql b/coderd/database/migrations/000566_audit_mcp_server_config.down.sql new file mode 100644 index 00000000000..35020b349fc --- /dev/null +++ b/coderd/database/migrations/000566_audit_mcp_server_config.down.sql @@ -0,0 +1 @@ +-- No-op, enum values can't be dropped. diff --git a/coderd/database/migrations/000566_audit_mcp_server_config.up.sql b/coderd/database/migrations/000566_audit_mcp_server_config.up.sql new file mode 100644 index 00000000000..f974064743a --- /dev/null +++ b/coderd/database/migrations/000566_audit_mcp_server_config.up.sql @@ -0,0 +1,2 @@ +ALTER TYPE resource_type + ADD VALUE IF NOT EXISTS 'mcp_server_config'; diff --git a/coderd/database/models.go b/coderd/database/models.go index e49b0ad042f..03b7b2584d0 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3551,6 +3551,7 @@ const ( ResourceTypeUserAIBudgetOverride ResourceType = "user_ai_budget_override" ResourceTypeOauth2ProviderSettings ResourceType = "oauth2_provider_settings" ResourceTypeChatInstructionSettings ResourceType = "chat_instruction_settings" + ResourceTypeMCPServerConfig ResourceType = "mcp_server_config" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3626,7 +3627,8 @@ func (e ResourceType) Valid() bool { ResourceTypeAIGatewayKey, ResourceTypeUserAIBudgetOverride, ResourceTypeOauth2ProviderSettings, - ResourceTypeChatInstructionSettings: + ResourceTypeChatInstructionSettings, + ResourceTypeMCPServerConfig: return true } return false @@ -3671,6 +3673,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeUserAIBudgetOverride, ResourceTypeOauth2ProviderSettings, ResourceTypeChatInstructionSettings, + ResourceTypeMCPServerConfig, } } diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 690173902f0..f7d820780cd 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -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 diff --git a/coderd/mcp.go b/coderd/mcp.go index 70074189aad..1a437cd063c 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -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" @@ -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 @@ -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)) } @@ -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. + aReq.Old = httpmw.MCPServerConfigParam(r) + aReq.UpdateOrganizationID(aReq.Old.OrganizationID) + existing, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionUpdate) if !ok { return @@ -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)) } @@ -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. + aReq.Old = httpmw.MCPServerConfigParam(r) + aReq.UpdateOrganizationID(aReq.Old.OrganizationID) + config, ok := api.getMCPServerConfigForMutation(rw, r, policy.ActionDelete) if !ok { return diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 01ad6805ca8..b8650595bfb 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -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" @@ -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() diff --git a/codersdk/audit.go b/codersdk/audit.go index 7ee41ce5158..96b718a86ac 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -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" @@ -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: diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 27082192dce..73dce51db7c 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -32,6 +32,7 @@ We track the following resources: | GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| | HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| | License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| MCPServerConfig
create, write, delete | |
FieldTracked
allow_in_plan_modetrue
api_key_headertrue
api_key_valuetrue
api_key_value_key_idfalse
auth_typetrue
availabilitytrue
created_atfalse
created_bytrue
custom_headerstrue
custom_headers_key_idfalse
descriptiontrue
display_nametrue
enabledtrue
forward_coder_headerstrue
icon_urltrue
idfalse
model_intenttrue
oauth2_auth_urltrue
oauth2_client_idtrue
oauth2_client_secrettrue
oauth2_client_secret_key_idfalse
oauth2_revocation_urltrue
oauth2_scopestrue
oauth2_token_urltrue
organization_idfalse
slugtrue
tool_allow_listtrue
tool_deny_listtrue
transporttrue
updated_atfalse
updated_bytrue
urltrue
| | NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| | NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| | OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 840463805f4..3a2949df159 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -11706,7 +11706,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | Value(s) | |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `chat_instruction_settings`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `oauth2_provider_settings`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | +| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `chat_instruction_settings`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `mcp_server_config`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `oauth2_provider_settings`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | ## codersdk.Response diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index 3a4eea8480f..2cdd5d8bc60 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -2,6 +2,7 @@ package audit import ( "database/sql" + "encoding/json" "reflect" "testing" "time" @@ -559,6 +560,100 @@ func Test_diff(t *testing.T) { exp: audit.Map{}, }, }) + + runDiffTests(t, []diffTest{ + { + name: "Create", + left: audit.Empty[database.MCPServerConfig](), + right: database.MCPServerConfig{ + ID: uuid.UUID{1}, + DisplayName: "GitHub MCP", + Slug: "github", + Url: "https://mcp.example.com/v1", + AuthType: "api_key", + APIKeyHeader: "X-Api-Key", + APIKeyValue: "plaintext-api-key", + CustomHeaders: `{"Authorization":"Bearer plaintext-header"}`, + OAuth2ClientSecret: "plaintext-oauth-secret", + Enabled: true, + CreatedBy: uuid.NullUUID{UUID: uuid.UUID{2}, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: uuid.UUID{2}, Valid: true}, + OrganizationID: uuid.UUID{4}, + }, + exp: audit.Map{ + "display_name": audit.OldNew{Old: "", New: "GitHub MCP"}, + "slug": audit.OldNew{Old: "", New: "github"}, + "url": audit.OldNew{Old: "", New: "https://mcp.example.com/v1"}, + "auth_type": audit.OldNew{Old: "", New: "api_key"}, + "api_key_header": audit.OldNew{Old: "", New: "X-Api-Key"}, + "api_key_value": audit.OldNew{Old: "", New: "", Secret: true}, + "custom_headers": audit.OldNew{Old: "", New: "", Secret: true}, + "oauth2_client_secret": audit.OldNew{Old: "", New: "", Secret: true}, + "enabled": audit.OldNew{Old: false, New: true}, + "created_by": audit.OldNew{Old: "null", New: uuid.UUID{2}.String()}, + "updated_by": audit.OldNew{Old: "null", New: uuid.UUID{2}.String()}, + }, + }, + { + // Rotating credentials renders a redacted change marker and + // the dbcrypt key-id bookkeeping stays out of the diff. + name: "SecretRotationRedacted", + left: database.MCPServerConfig{ + ID: uuid.UUID{1}, + DisplayName: "GitHub MCP", + AuthType: "api_key", + APIKeyValue: "old-plaintext-api-key", + APIKeyValueKeyID: sql.NullString{String: "key-1", Valid: true}, + CustomHeaders: `{"Authorization":"Bearer old-plaintext"}`, + CustomHeadersKeyID: sql.NullString{String: "key-1", Valid: true}, + OrganizationID: uuid.UUID{4}, + }, + right: database.MCPServerConfig{ + ID: uuid.UUID{1}, + DisplayName: "Renamed MCP", + AuthType: "api_key", + APIKeyValue: "new-plaintext-api-key", + CustomHeaders: `{"Authorization":"Bearer new-plaintext"}`, + OrganizationID: uuid.UUID{4}, + }, + exp: audit.Map{ + "display_name": audit.OldNew{Old: "GitHub MCP", New: "Renamed MCP"}, + "api_key_value": audit.OldNew{Old: "", New: "", Secret: true}, + "custom_headers": audit.OldNew{Old: "", New: "", Secret: true}, + }, + }, + }) +} + +// Test_mcpServerConfigSecretsNeverSerialized proves the serialized audit +// diff for an MCP server config never carries secret material, mirroring +// how coderd/audit.InitRequest marshals the diff into the audit log row. +func Test_mcpServerConfigSecretsNeverSerialized(t *testing.T) { + t.Parallel() + + secrets := []string{ + "plaintext-oauth-secret", + "plaintext-api-key", + "Bearer plaintext-header", + } + left := audit.Empty[database.MCPServerConfig]() + right := database.MCPServerConfig{ + ID: uuid.UUID{1}, + DisplayName: "GitHub MCP", + AuthType: "oauth2", + OAuth2ClientID: "client-id", + OAuth2ClientSecret: secrets[0], + APIKeyValue: secrets[1], + CustomHeaders: `{"Authorization":"` + secrets[2] + `"}`, + OrganizationID: uuid.UUID{4}, + } + + raw, err := json.Marshal(diffValues(left, right, AuditableResources)) + require.NoError(t, err) + for _, secret := range secrets { + require.NotContains(t, string(raw), secret) + } + require.Contains(t, string(raw), "client-id") } func runDiffTests(t *testing.T, tests []diffTest) { diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 4e1082a67b1..d1d98c9f565 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -35,6 +35,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "AuditableGroupAIBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "AuditableUserAIBudgetOverride": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. + "MCPServerConfig": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "UserSkill": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "ChatInstructionSettings": {codersdk.AuditActionWrite}, @@ -501,6 +502,40 @@ var auditableResourcesTypes = map[any]map[string]Action{ "requires_action_deadline_at": ActionIgnore, // Internal pending-action deadline. "compaction_requested_at": ActionIgnore, // Internal one-shot manual compaction signal. }, + &database.MCPServerConfig{}: { + "id": ActionIgnore, // Conveyed by resource_id, not useful in a diff. + "display_name": ActionTrack, + "slug": ActionTrack, + "description": ActionTrack, + "icon_url": ActionTrack, + "transport": ActionTrack, + "url": ActionTrack, + "auth_type": ActionTrack, + "oauth2_client_id": ActionTrack, + "oauth2_client_secret": ActionSecret, // Credential; show change, never contents. + "oauth2_client_secret_key_id": ActionIgnore, // dbcrypt bookkeeping. + "oauth2_auth_url": ActionTrack, + "oauth2_token_url": ActionTrack, + "oauth2_scopes": ActionTrack, + "api_key_header": ActionTrack, + "api_key_value": ActionSecret, // Credential; show change, never contents. + "api_key_value_key_id": ActionIgnore, // dbcrypt bookkeeping. + "custom_headers": ActionSecret, // Header values are credentials; show change, never contents. + "custom_headers_key_id": ActionIgnore, // dbcrypt bookkeeping. + "tool_allow_list": ActionTrack, + "tool_deny_list": ActionTrack, + "availability": ActionTrack, + "enabled": ActionTrack, + "created_by": ActionTrack, + "updated_by": ActionTrack, + "created_at": ActionIgnore, // Never changes. + "updated_at": ActionIgnore, // Bumped on every mutation. + "model_intent": ActionTrack, + "allow_in_plan_mode": ActionTrack, + "forward_coder_headers": ActionTrack, + "oauth2_revocation_url": ActionTrack, + "organization_id": ActionIgnore, // Never changes; carried by the audit log's organization ID. + }, &database.UserSkill{}: { "id": ActionTrack, "user_id": ActionTrack, diff --git a/enterprise/coderd/mcp_test.go b/enterprise/coderd/mcp_test.go index 353b5b37e54..1e8a2429d85 100644 --- a/enterprise/coderd/mcp_test.go +++ b/enterprise/coderd/mcp_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "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" @@ -103,7 +104,11 @@ func TestMCPServerConfigCollectionOrganizationIsolation(t *testing.T) { func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { t.Parallel() + mAudit := audit.NewMock() client, firstUser := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Auditor: mAudit, + }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ codersdk.FeatureMultipleOrganizations: 1, @@ -115,6 +120,9 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { config := createMCPServerConfigForOrganization(t, client, firstUser.OrganizationID, "private-org-one-mcp") organizationPath := "/api/experimental/organizations/" + secondOrg.ID.String() + "/mcp-servers/" + config.ID.String() frozenPath := "/api/experimental/mcp/servers/" + config.ID.String() + // Drop the audited setup create so the subtests can assert the + // denied requests below record nothing. + mAudit.ResetLogs() for _, test := range []struct { name string @@ -140,6 +148,13 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { wantStatus = http.StatusNotFound } requireMCPServerConfigRequestStatus(t, otherClient, test.method, test.path, test.body, wantStatus) + + // The read-denied 404 comes from the param middleware before + // any mutation handler runs, so no audit entry is recorded + // for the concealed config. + for _, log := range mAudit.AuditLogs() { + require.NotEqual(t, database.ResourceTypeMCPServerConfig, log.ResourceType) + } }) } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8c93fd9e509..058ae1d859b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7968,6 +7968,7 @@ export type ResourceType = | "idp_sync_settings_organization" | "idp_sync_settings_role" | "license" + | "mcp_server_config" | "notification_template" | "notifications_settings" | "oauth2_provider_app" @@ -8007,6 +8008,7 @@ export const ResourceTypes: ResourceType[] = [ "idp_sync_settings_organization", "idp_sync_settings_role", "license", + "mcp_server_config", "notification_template", "notifications_settings", "oauth2_provider_app", From f7d49452ce195e942eb9982905d1722d9bdb20a2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:25:59 +0000 Subject: [PATCH 02/24] chore: polish CODAGT-717 comments per cleanup gate --- coderd/mcp_test.go | 2 -- enterprise/audit/diff_internal_test.go | 5 ----- enterprise/audit/table.go | 4 ++-- enterprise/coderd/mcp_test.go | 8 +++----- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index b8650595bfb..c0159a2f0b9 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -361,8 +361,6 @@ func TestMCPServerConfigsAudit(t *testing.T) { 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() diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index 2cdd5d8bc60..f1e14d91ce9 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -595,8 +595,6 @@ func Test_diff(t *testing.T) { }, }, { - // Rotating credentials renders a redacted change marker and - // the dbcrypt key-id bookkeeping stays out of the diff. name: "SecretRotationRedacted", left: database.MCPServerConfig{ ID: uuid.UUID{1}, @@ -625,9 +623,6 @@ func Test_diff(t *testing.T) { }) } -// Test_mcpServerConfigSecretsNeverSerialized proves the serialized audit -// diff for an MCP server config never carries secret material, mirroring -// how coderd/audit.InitRequest marshals the diff into the audit log row. func Test_mcpServerConfigSecretsNeverSerialized(t *testing.T) { t.Parallel() diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index d1d98c9f565..536d067f354 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -520,7 +520,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "api_key_header": ActionTrack, "api_key_value": ActionSecret, // Credential; show change, never contents. "api_key_value_key_id": ActionIgnore, // dbcrypt bookkeeping. - "custom_headers": ActionSecret, // Header values are credentials; show change, never contents. + "custom_headers": ActionSecret, // May contain credentials; show change, never contents. "custom_headers_key_id": ActionIgnore, // dbcrypt bookkeeping. "tool_allow_list": ActionTrack, "tool_deny_list": ActionTrack, @@ -534,7 +534,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "allow_in_plan_mode": ActionTrack, "forward_coder_headers": ActionTrack, "oauth2_revocation_url": ActionTrack, - "organization_id": ActionIgnore, // Never changes; carried by the audit log's organization ID. + "organization_id": ActionIgnore, // Never changes after creation; carried by the audit log's organization ID. }, &database.UserSkill{}: { "id": ActionTrack, diff --git a/enterprise/coderd/mcp_test.go b/enterprise/coderd/mcp_test.go index 1e8a2429d85..02801b5e64f 100644 --- a/enterprise/coderd/mcp_test.go +++ b/enterprise/coderd/mcp_test.go @@ -120,8 +120,6 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { config := createMCPServerConfigForOrganization(t, client, firstUser.OrganizationID, "private-org-one-mcp") organizationPath := "/api/experimental/organizations/" + secondOrg.ID.String() + "/mcp-servers/" + config.ID.String() frozenPath := "/api/experimental/mcp/servers/" + config.ID.String() - // Drop the audited setup create so the subtests can assert the - // denied requests below record nothing. mAudit.ResetLogs() for _, test := range []struct { @@ -149,9 +147,9 @@ func TestMCPServerConfigItemCrossOrganizationConcealment(t *testing.T) { } requireMCPServerConfigRequestStatus(t, otherClient, test.method, test.path, test.body, wantStatus) - // The read-denied 404 comes from the param middleware before - // any mutation handler runs, so no audit entry is recorded - // for the concealed config. + // Read-gated routes 404 in the param middleware before any + // handler runs, and disconnect does not audit, so nothing + // is audited. for _, log := range mAudit.AuditLogs() { require.NotEqual(t, database.ResourceTypeMCPServerConfig, log.ResourceType) } From 968281500d47d67de0568c06462985f313dfc968 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:47:05 +0000 Subject: [PATCH 03/24] test(coderd): audit denied MCP server config deletions --- coderd/audit/request.go | 6 ++---- coderd/mcp_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/coderd/audit/request.go b/coderd/audit/request.go index d09b2fe0606..4fa49729f46 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -158,8 +158,7 @@ func ResourceTarget[T Auditable](tgt T) string { 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. + // The full ID equals resource_id, so the label is exactly filterable. return typed.ID.String() case database.UserSecret: return typed.Name @@ -437,8 +436,7 @@ func ResourceRequiresOrgID[T Auditable]() bool { // migration 000467). return true case database.MCPServerConfig: - // MCP server configs are org-scoped; every row carries a - // non-null organization_id. + // MCP server configs always carry a non-null organization_id. return true case database.UserSecret: // User secrets are global to the user across organizations. diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index c0159a2f0b9..87657e9f2a2 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -388,6 +388,31 @@ func TestMCPServerConfigsAudit(t *testing.T) { require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID) require.EqualValues(t, http.StatusForbidden, logs[0].StatusCode) }) + + t.Run("DeleteDeniedAudited", 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-delete-denied", true) + + mAudit.ResetLogs() + err := memberClient.DeleteMCPServerConfig(ctx, config.ID) + 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.AuditActionDelete, 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) { From a72d5cfcc9beb8331936b7541966099d875ba6ef Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:33:26 +0000 Subject: [PATCH 04/24] fix(enterprise/audit): redact MCP server endpoint URLs in audit diffs --- enterprise/audit/diff_internal_test.go | 4 +++- enterprise/audit/table.go | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index f1e14d91ce9..eee1087a592 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -583,7 +583,7 @@ func Test_diff(t *testing.T) { exp: audit.Map{ "display_name": audit.OldNew{Old: "", New: "GitHub MCP"}, "slug": audit.OldNew{Old: "", New: "github"}, - "url": audit.OldNew{Old: "", New: "https://mcp.example.com/v1"}, + "url": audit.OldNew{Old: "", New: "", Secret: true}, "auth_type": audit.OldNew{Old: "", New: "api_key"}, "api_key_header": audit.OldNew{Old: "", New: "X-Api-Key"}, "api_key_value": audit.OldNew{Old: "", New: "", Secret: true}, @@ -630,12 +630,14 @@ func Test_mcpServerConfigSecretsNeverSerialized(t *testing.T) { "plaintext-oauth-secret", "plaintext-api-key", "Bearer plaintext-header", + "url-embedded-credential", } left := audit.Empty[database.MCPServerConfig]() right := database.MCPServerConfig{ ID: uuid.UUID{1}, DisplayName: "GitHub MCP", AuthType: "oauth2", + Url: "https://mcp.example.com/v1?api_key=" + secrets[3], OAuth2ClientID: "client-id", OAuth2ClientSecret: secrets[0], APIKeyValue: secrets[1], diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 536d067f354..3f59603f251 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -509,13 +509,13 @@ var auditableResourcesTypes = map[any]map[string]Action{ "description": ActionTrack, "icon_url": ActionTrack, "transport": ActionTrack, - "url": ActionTrack, + "url": ActionSecret, // May embed credentials in userinfo or query; show change, never contents. "auth_type": ActionTrack, "oauth2_client_id": ActionTrack, "oauth2_client_secret": ActionSecret, // Credential; show change, never contents. "oauth2_client_secret_key_id": ActionIgnore, // dbcrypt bookkeeping. - "oauth2_auth_url": ActionTrack, - "oauth2_token_url": ActionTrack, + "oauth2_auth_url": ActionSecret, // May embed credentials in userinfo or query; show change, never contents. + "oauth2_token_url": ActionSecret, // May embed credentials in userinfo or query; show change, never contents. "oauth2_scopes": ActionTrack, "api_key_header": ActionTrack, "api_key_value": ActionSecret, // Credential; show change, never contents. @@ -533,7 +533,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "model_intent": ActionTrack, "allow_in_plan_mode": ActionTrack, "forward_coder_headers": ActionTrack, - "oauth2_revocation_url": ActionTrack, + "oauth2_revocation_url": ActionSecret, // May embed credentials in userinfo or query; show change, never contents. "organization_id": ActionIgnore, // Never changes after creation; carried by the audit log's organization ID. }, &database.UserSkill{}: { From 0a32b24f87da0ab5ce4127a8664ef698daa19eaf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:35:34 +0000 Subject: [PATCH 05/24] fix(coderd): mark deleted MCP server configs in audit logs --- coderd/audit.go | 12 ++++++++++++ coderd/mcp_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/coderd/audit.go b/coderd/audit.go index 44ed30770bc..13356ea32b0 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -501,6 +501,18 @@ func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.Get api.Logger.Error(ctx, "unable to fetch chat", slog.Error(err)) } return false + case database.ResourceTypeMCPServerConfig: + // MCP server configs are hard-deleted, so a 404 means deleted. + _, err := api.Database.GetMCPServerConfigByID(ctx, alog.AuditLog.ResourceID) + if xerrors.Is(err, sql.ErrNoRows) { + return true + } + // Config reads are org-scoped, so an auditor can lack read on + // the config's organization. That is not worth logging. + if err != nil && !dbauthz.IsNotAuthorizedError(err) { + api.Logger.Error(ctx, "unable to fetch mcp server config", slog.Error(err)) + } + return false case database.ResourceTypeUserSecret: _, err := api.Database.GetUserSecretByID(ctx, alog.AuditLog.ResourceID) if xerrors.Is(err, sql.ErrNoRows) { diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 87657e9f2a2..9ed7c5c3698 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -361,6 +361,44 @@ func TestMCPServerConfigsAudit(t *testing.T) { require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) }) + t.Run("DeletedResourceMarked", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, _ := newAuditedMCPClient(t) + firstUser := coderdtest.CreateFirstUser(t, client) + config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-is-deleted", true) + deletedID := uuid.New() + + err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{ + OrganizationID: firstUser.OrganizationID, + Action: codersdk.AuditActionWrite, + ResourceType: codersdk.ResourceTypeMCPServerConfig, + ResourceID: config.ID, + }) + require.NoError(t, err) + err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{ + OrganizationID: firstUser.OrganizationID, + Action: codersdk.AuditActionDelete, + ResourceType: codersdk.ResourceTypeMCPServerConfig, + ResourceID: deletedID, + }) + require.NoError(t, err) + + logs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{ + Pagination: codersdk.Pagination{Limit: 25}, + }) + require.NoError(t, err) + byResourceID := make(map[uuid.UUID]codersdk.AuditLog, len(logs.AuditLogs)) + for _, alog := range logs.AuditLogs { + byResourceID[alog.ResourceID] = alog + } + require.Contains(t, byResourceID, config.ID) + require.False(t, byResourceID[config.ID].IsDeleted) + require.Contains(t, byResourceID, deletedID) + require.True(t, byResourceID[deletedID].IsDeleted) + }) + t.Run("WriteDeniedAudited", func(t *testing.T) { t.Parallel() From 08a7219b7273d868a4c174ffe14bbdadacaa770c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:57:11 +0000 Subject: [PATCH 06/24] fix(coderd): make MCP config audit records match persisted rows --- coderd/mcp.go | 11 ++++++++--- coderd/mcp_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 1a437cd063c..4e932d24ac7 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -620,14 +620,19 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { var updated database.MCPServerConfig err := api.Database.InTx(func(tx database.Store) error { - // Lock and re-fetch the row so omitted fields come from the latest - // version and grant invalidation serializes with in-flight OAuth - // callbacks verifying the same config. + // Re-fetch under a row lock so the merge base and the audit + // baseline are the row this update actually replaces, and so + // grant invalidation serializes with in-flight OAuth callbacks + // verifying the same config. The middleware snapshot may be + // stale by now, and merging onto it would silently revert a + // concurrent update without recording the reverted fields in + // the audit diff. current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, existing.ID) if err != nil { return err } existing = current + aReq.Old = current touchesUserOIDC := existing.AuthType == "user_oidc" || (req.AuthType != nil && *req.AuthType == "user_oidc") diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 9ed7c5c3698..f989fba145a 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -361,6 +361,32 @@ func TestMCPServerConfigsAudit(t *testing.T) { require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) }) + t.Run("AutoDiscoveryFailureNotAudited", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, mAudit := newAuditedMCPClient(t) + firstUser := coderdtest.CreateFirstUser(t, client) + + mAudit.ResetLogs() + // Discovery fails immediately: nothing listens on the URL. + // The partially inserted row is cleaned up, so no audit + // entry may reference it. + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Audit Discovery Failure", + Slug: "audit-discovery-failure", + Transport: "streamable_http", + URL: "http://127.0.0.1:1", + AuthType: "oauth2", + Availability: "default_on", + Enabled: true, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Empty(t, mAudit.AuditLogs()) + }) + t.Run("DeletedResourceMarked", func(t *testing.T) { t.Parallel() From 09fb6fb7919f3042b714a021501759cce57add80 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:25:36 +0000 Subject: [PATCH 07/24] fix(coderd): audit MCP config deletions against the locked row --- coderd/mcp.go | 17 +++++++++++++++- coderd/mcp_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 4e932d24ac7..3b2770a0323 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -942,7 +942,22 @@ func (api *API) deleteMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } - if err := api.Database.DeleteMCPServerConfigByID(ctx, config.ID); err != nil { + err := api.Database.InTx(func(tx database.Store) error { + // Re-fetch under a row lock so the audit record describes the + // row this request actually removes, not a middleware snapshot + // that a concurrent update may have made stale. + current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, config.ID) + if err != nil { + return err + } + aReq.Old = current + return tx.DeleteMCPServerConfigByID(ctx, current.ID) + }, nil) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to delete MCP server config.", Detail: err.Error(), diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index f989fba145a..f012b301a36 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -361,6 +362,36 @@ func TestMCPServerConfigsAudit(t *testing.T) { require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode) }) + t.Run("DeleteAuditsPersistedRow", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + mAudit := audit.NewMock() + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + db, ps := dbtestutil.NewDB(t) + store := &staleMCPServerConfigReadStore{Store: db} + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + Auditor: mAudit, + Database: store, + Pubsub: ps, + }) + firstUser := coderdtest.CreateFirstUser(t, client) + config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete-stale", true) + + // Simulate a concurrent update landing between the param + // middleware read and the delete transaction. + store.stale.Store(true) + mAudit.ResetLogs() + err := client.DeleteMCPServerConfig(ctx, config.ID) + require.NoError(t, err) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, config.DisplayName, logs[0].ResourceTarget) + }) + t.Run("AutoDiscoveryFailureNotAudited", func(t *testing.T) { t.Parallel() @@ -479,6 +510,23 @@ func TestMCPServerConfigsAudit(t *testing.T) { }) } +// staleMCPServerConfigReadStore corrupts plain config reads once armed, +// simulating a concurrent update that outdates the param middleware's +// snapshot. Locked ForUpdate reads stay untouched. +type staleMCPServerConfigReadStore struct { + database.Store + + stale atomic.Bool +} + +func (s *staleMCPServerConfigReadStore) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) { + config, err := s.Store.GetMCPServerConfigByID(ctx, id) + if err == nil && s.stale.Load() { + config.DisplayName = "stale middleware snapshot" + } + return config, err +} + func TestMCPServerConfigsNonAdmin(t *testing.T) { t.Parallel() From 86d42a7fa3e42d9e95a4a75a5f17f6dea8305a97 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:05:38 +0000 Subject: [PATCH 08/24] chore(coderd/database/migrations): renumber migration 000566 to 000569 Migrations 000567 and 000568 were claimed on main and by the parent branch while this stack was in review. --- ...er_config.down.sql => 000569_audit_mcp_server_config.down.sql} | 0 ...server_config.up.sql => 000569_audit_mcp_server_config.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000566_audit_mcp_server_config.down.sql => 000569_audit_mcp_server_config.down.sql} (100%) rename coderd/database/migrations/{000566_audit_mcp_server_config.up.sql => 000569_audit_mcp_server_config.up.sql} (100%) diff --git a/coderd/database/migrations/000566_audit_mcp_server_config.down.sql b/coderd/database/migrations/000569_audit_mcp_server_config.down.sql similarity index 100% rename from coderd/database/migrations/000566_audit_mcp_server_config.down.sql rename to coderd/database/migrations/000569_audit_mcp_server_config.down.sql diff --git a/coderd/database/migrations/000566_audit_mcp_server_config.up.sql b/coderd/database/migrations/000569_audit_mcp_server_config.up.sql similarity index 100% rename from coderd/database/migrations/000566_audit_mcp_server_config.up.sql rename to coderd/database/migrations/000569_audit_mcp_server_config.up.sql From 197a759430290c44494a8bf2687a092e76be3242 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:09:34 +0000 Subject: [PATCH 09/24] chore(coderd/database/migrations): renumber migration 000569 to 000570 Main claimed 000568, shifting the org-scope migration to 000569. --- ...er_config.down.sql => 000570_audit_mcp_server_config.down.sql} | 0 ...server_config.up.sql => 000570_audit_mcp_server_config.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000569_audit_mcp_server_config.down.sql => 000570_audit_mcp_server_config.down.sql} (100%) rename coderd/database/migrations/{000569_audit_mcp_server_config.up.sql => 000570_audit_mcp_server_config.up.sql} (100%) diff --git a/coderd/database/migrations/000569_audit_mcp_server_config.down.sql b/coderd/database/migrations/000570_audit_mcp_server_config.down.sql similarity index 100% rename from coderd/database/migrations/000569_audit_mcp_server_config.down.sql rename to coderd/database/migrations/000570_audit_mcp_server_config.down.sql diff --git a/coderd/database/migrations/000569_audit_mcp_server_config.up.sql b/coderd/database/migrations/000570_audit_mcp_server_config.up.sql similarity index 100% rename from coderd/database/migrations/000569_audit_mcp_server_config.up.sql rename to coderd/database/migrations/000570_audit_mcp_server_config.up.sql From a04fd6f06fbd2978147360e2e3051391ddc4798f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:11:10 +0000 Subject: [PATCH 10/24] fix(coderd): address MCP audit review feedback Require a non-empty display name on create and update so the audit resource target logic needs no ID fallback, link MCP config audit entries to their settings page, trim audit table comment noise, and pin the created-row audit guarantee when the post-discovery credential update fails. --- coderd/audit.go | 2 + coderd/audit/request.go | 6 +- coderd/audit_test.go | 30 +++++++++ coderd/mcp.go | 15 +++++ coderd/mcp_test.go | 127 ++++++++++++++++++++++++++++++++++++++ enterprise/audit/table.go | 20 +++--- 6 files changed, 185 insertions(+), 15 deletions(-) diff --git a/coderd/audit.go b/coderd/audit.go index 13356ea32b0..223ee772dda 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -616,6 +616,8 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit // Chats are surfaced at /agents/{id}. They are owner-scoped but // not username-scoped in the URL like workspaces or tasks. return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID) + case database.ResourceTypeMCPServerConfig: + return fmt.Sprintf("/ai/settings/mcp-servers/%s", alog.AuditLog.ResourceID) case database.ResourceTypeUserSecret: // TODO(PLAT-102): point at the user secrets management page once // it ships. Until then, the audit row links nowhere. diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 4fa49729f46..7068283aeb5 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -155,11 +155,7 @@ func ResourceTarget[T Auditable](tgt T) string { // filter but not the primary resource identifier. return typed.ID.String()[:8] case database.MCPServerConfig: - if typed.DisplayName != "" { - return typed.DisplayName - } - // The full ID equals resource_id, so the label is exactly filterable. - return typed.ID.String() + return typed.DisplayName case database.UserSecret: return typed.Name case database.UserSkill: diff --git a/coderd/audit_test.go b/coderd/audit_test.go index 721e133fc7d..bf33c6f8120 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" @@ -143,6 +144,35 @@ func TestAuditLogs(t *testing.T) { workspace.OwnerName, workspace.Name, buildNumberString)) }) + t.Run("MCPServerConfigAuditLink", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + db, ps := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) + user := coderdtest.CreateFirstUser(t, client) + + config := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + OrganizationID: user.OrganizationID, + }) + err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{ + Action: codersdk.AuditActionCreate, + ResourceType: codersdk.ResourceTypeMCPServerConfig, + ResourceID: config.ID, + OrganizationID: user.OrganizationID, + }) + require.NoError(t, err) + + auditLogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{ + Pagination: codersdk.Pagination{ + Limit: 1, + }, + }) + require.NoError(t, err) + require.Len(t, auditLogs.AuditLogs, 1) + require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s", config.ID), auditLogs.AuditLogs[0].ResourceLink) + }) + t.Run("Organization", func(t *testing.T) { t.Parallel() diff --git a/coderd/mcp.go b/coderd/mcp.go index 3b2770a0323..25eac3bfaca 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -272,6 +272,14 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } + // The struct tag accepts whitespace-only names, which trim to "". + if strings.TrimSpace(req.DisplayName) == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Display name is required.", + }) + return + } + if trimmed := strings.TrimSpace(req.OAuth2RevocationURL); trimmed != "" { if err := mcpclient.ValidateRevocationEndpoint(trimmed); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -581,6 +589,13 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } + if req.DisplayName != nil && strings.TrimSpace(*req.DisplayName) == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Display name cannot be empty.", + }) + return + } + // Validated here rather than via a struct tag because an empty // string is a valid value that clears the stored URL. if req.OAuth2RevocationURL != nil { diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index f012b301a36..da98e3c8667 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -21,6 +21,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" @@ -29,6 +30,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -139,6 +141,35 @@ func TestMCPServerConfigLegacyRoutesRemoved(t *testing.T) { } } +func TestMCPServerConfigDisplayNameValidation(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newMCPClient(t) + firstUser := coderdtest.CreateFirstUser(t, client) + + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: " ", + Slug: "whitespace-name", + Transport: "streamable_http", + URL: "https://mcp.example.com", + AuthType: "none", + Availability: "default_off", + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + + config := createMCPServerConfig(t, client, firstUser.OrganizationID, "display-name-validation", true) + for _, name := range []string{"", " "} { + _, err := client.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ + DisplayName: ptr.Ref(name), + }) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + } +} + func TestMCPServerConfigsCRUD(t *testing.T) { t.Parallel() @@ -418,6 +449,87 @@ func TestMCPServerConfigsAudit(t *testing.T) { require.Empty(t, mAudit.AuditLogs()) }) + t.Run("CreateAuditedWhenDiscoveryUpdateFails", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + mAudit := audit.NewMock() + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + db, ps := dbtestutil.NewDB(t) + store := &failingMCPServerConfigUpdateStore{Store: db} + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + Auditor: mAudit, + Database: store, + Pubsub: ps, + }) + firstUser := coderdtest.CreateFirstUser(t, client) + + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-authorization-server": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "` + r.Host + `", + "authorization_endpoint": "` + "http://" + r.Host + `/authorize", + "token_endpoint": "` + "http://" + r.Host + `/token", + "registration_endpoint": "` + "http://" + r.Host + `/register", + "response_types_supported": ["code"] + }`)) + case "/register": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "client_id": "update-failure-client-id", + "client_secret": "update-failure-client-secret" + }`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(authServer.Close) + + mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-protected-resource/v1/mcp": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "resource": "` + "http://" + r.Host + `", + "authorization_servers": ["` + authServer.URL + `"] + }`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(mcpServer.Close) + + // The inserted row must still be audited when the post-discovery update fails. + store.fail.Store(true) + mAudit.ResetLogs() + _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Audit Update Failure", + Slug: "audit-update-failure", + Transport: "streamable_http", + URL: mcpServer.URL + "/v1/mcp", + AuthType: "oauth2", + Availability: "default_on", + Enabled: true, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode()) + + configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID) + require.NoError(t, err) + require.Len(t, configs, 1) + + logs := mAudit.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, database.AuditActionCreate, logs[0].Action) + require.Equal(t, configs[0].ID, logs[0].ResourceID) + }) + t.Run("DeletedResourceMarked", func(t *testing.T) { t.Parallel() @@ -510,6 +622,21 @@ func TestMCPServerConfigsAudit(t *testing.T) { }) } +// failingMCPServerConfigUpdateStore fails config updates once armed, +// outdating the discovery flow's post-insert credential update. +type failingMCPServerConfigUpdateStore struct { + database.Store + + fail atomic.Bool +} + +func (s *failingMCPServerConfigUpdateStore) UpdateMCPServerConfig(ctx context.Context, arg database.UpdateMCPServerConfigParams) (database.MCPServerConfig, error) { + if s.fail.Load() { + return database.MCPServerConfig{}, xerrors.New("injected update failure") + } + return s.Store.UpdateMCPServerConfig(ctx, arg) +} + // staleMCPServerConfigReadStore corrupts plain config reads once armed, // simulating a concurrent update that outdates the param middleware's // snapshot. Locked ForUpdate reads stay untouched. diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 3f59603f251..ba8d79ea11e 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -509,18 +509,18 @@ var auditableResourcesTypes = map[any]map[string]Action{ "description": ActionTrack, "icon_url": ActionTrack, "transport": ActionTrack, - "url": ActionSecret, // May embed credentials in userinfo or query; show change, never contents. + "url": ActionSecret, // May embed credentials in userinfo or query "auth_type": ActionTrack, "oauth2_client_id": ActionTrack, - "oauth2_client_secret": ActionSecret, // Credential; show change, never contents. + "oauth2_client_secret": ActionSecret, "oauth2_client_secret_key_id": ActionIgnore, // dbcrypt bookkeeping. - "oauth2_auth_url": ActionSecret, // May embed credentials in userinfo or query; show change, never contents. - "oauth2_token_url": ActionSecret, // May embed credentials in userinfo or query; show change, never contents. + "oauth2_auth_url": ActionSecret, // May embed credentials in userinfo or query + "oauth2_token_url": ActionSecret, // May embed credentials in userinfo or query "oauth2_scopes": ActionTrack, "api_key_header": ActionTrack, - "api_key_value": ActionSecret, // Credential; show change, never contents. + "api_key_value": ActionSecret, "api_key_value_key_id": ActionIgnore, // dbcrypt bookkeeping. - "custom_headers": ActionSecret, // May contain credentials; show change, never contents. + "custom_headers": ActionSecret, // May contain credentials "custom_headers_key_id": ActionIgnore, // dbcrypt bookkeeping. "tool_allow_list": ActionTrack, "tool_deny_list": ActionTrack, @@ -528,13 +528,13 @@ var auditableResourcesTypes = map[any]map[string]Action{ "enabled": ActionTrack, "created_by": ActionTrack, "updated_by": ActionTrack, - "created_at": ActionIgnore, // Never changes. - "updated_at": ActionIgnore, // Bumped on every mutation. + "created_at": ActionIgnore, + "updated_at": ActionIgnore, "model_intent": ActionTrack, "allow_in_plan_mode": ActionTrack, "forward_coder_headers": ActionTrack, - "oauth2_revocation_url": ActionSecret, // May embed credentials in userinfo or query; show change, never contents. - "organization_id": ActionIgnore, // Never changes after creation; carried by the audit log's organization ID. + "oauth2_revocation_url": ActionSecret, // May embed credentials in userinfo or query + "organization_id": ActionIgnore, }, &database.UserSkill{}: { "id": ActionTrack, From 6316be4c153510f439e4ed05099810459e23c9f2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:02:41 +0000 Subject: [PATCH 11/24] fix(enterprise/audit): track MCP server endpoint URLs in audit diffs Auditors need to see which endpoint a config points at, and AI provider base URLs are already tracked. Admins should keep credentials in the dedicated secret fields rather than URL query parameters. --- enterprise/audit/diff_internal_test.go | 4 +--- enterprise/audit/table.go | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index eee1087a592..f1e14d91ce9 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -583,7 +583,7 @@ func Test_diff(t *testing.T) { exp: audit.Map{ "display_name": audit.OldNew{Old: "", New: "GitHub MCP"}, "slug": audit.OldNew{Old: "", New: "github"}, - "url": audit.OldNew{Old: "", New: "", Secret: true}, + "url": audit.OldNew{Old: "", New: "https://mcp.example.com/v1"}, "auth_type": audit.OldNew{Old: "", New: "api_key"}, "api_key_header": audit.OldNew{Old: "", New: "X-Api-Key"}, "api_key_value": audit.OldNew{Old: "", New: "", Secret: true}, @@ -630,14 +630,12 @@ func Test_mcpServerConfigSecretsNeverSerialized(t *testing.T) { "plaintext-oauth-secret", "plaintext-api-key", "Bearer plaintext-header", - "url-embedded-credential", } left := audit.Empty[database.MCPServerConfig]() right := database.MCPServerConfig{ ID: uuid.UUID{1}, DisplayName: "GitHub MCP", AuthType: "oauth2", - Url: "https://mcp.example.com/v1?api_key=" + secrets[3], OAuth2ClientID: "client-id", OAuth2ClientSecret: secrets[0], APIKeyValue: secrets[1], diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index ba8d79ea11e..2eb419decf5 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -509,13 +509,13 @@ var auditableResourcesTypes = map[any]map[string]Action{ "description": ActionTrack, "icon_url": ActionTrack, "transport": ActionTrack, - "url": ActionSecret, // May embed credentials in userinfo or query + "url": ActionTrack, "auth_type": ActionTrack, "oauth2_client_id": ActionTrack, "oauth2_client_secret": ActionSecret, "oauth2_client_secret_key_id": ActionIgnore, // dbcrypt bookkeeping. - "oauth2_auth_url": ActionSecret, // May embed credentials in userinfo or query - "oauth2_token_url": ActionSecret, // May embed credentials in userinfo or query + "oauth2_auth_url": ActionTrack, + "oauth2_token_url": ActionTrack, "oauth2_scopes": ActionTrack, "api_key_header": ActionTrack, "api_key_value": ActionSecret, @@ -533,7 +533,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "model_intent": ActionTrack, "allow_in_plan_mode": ActionTrack, "forward_coder_headers": ActionTrack, - "oauth2_revocation_url": ActionSecret, // May embed credentials in userinfo or query + "oauth2_revocation_url": ActionTrack, "organization_id": ActionIgnore, }, &database.UserSkill{}: { From a848d84e73ca0104baaa9a340c07322c2600af93 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:38:08 +0000 Subject: [PATCH 12/24] fix(coderd/database): renumber MCP audit migration to 000571 Follows the org-scoping migration's move to 000570 after main took 000569. --- ...er_config.down.sql => 000571_audit_mcp_server_config.down.sql} | 0 ...server_config.up.sql => 000571_audit_mcp_server_config.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000570_audit_mcp_server_config.down.sql => 000571_audit_mcp_server_config.down.sql} (100%) rename coderd/database/migrations/{000570_audit_mcp_server_config.up.sql => 000571_audit_mcp_server_config.up.sql} (100%) diff --git a/coderd/database/migrations/000570_audit_mcp_server_config.down.sql b/coderd/database/migrations/000571_audit_mcp_server_config.down.sql similarity index 100% rename from coderd/database/migrations/000570_audit_mcp_server_config.down.sql rename to coderd/database/migrations/000571_audit_mcp_server_config.down.sql diff --git a/coderd/database/migrations/000570_audit_mcp_server_config.up.sql b/coderd/database/migrations/000571_audit_mcp_server_config.up.sql similarity index 100% rename from coderd/database/migrations/000570_audit_mcp_server_config.up.sql rename to coderd/database/migrations/000571_audit_mcp_server_config.up.sql From db41667f777ade17f0fd65c4eee9f00d23fa235a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:56:58 +0000 Subject: [PATCH 13/24] chore: shorten the audit layer reread comment per cleanup gate --- coderd/mcp.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 25eac3bfaca..071e4e4a2fa 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -635,13 +635,9 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { var updated database.MCPServerConfig err := api.Database.InTx(func(tx database.Store) error { - // Re-fetch under a row lock so the merge base and the audit - // baseline are the row this update actually replaces, and so - // grant invalidation serializes with in-flight OAuth callbacks - // verifying the same config. The middleware snapshot may be - // stale by now, and merging onto it would silently revert a - // concurrent update without recording the reverted fields in - // the audit diff. + // Lock and re-fetch the row so omitted fields and the audit baseline + // match the row this update replaces, and so grant invalidation + // serializes with in-flight OAuth callbacks verifying the config. current, err := tx.GetMCPServerConfigByIDForUpdate(ctx, existing.ID) if err != nil { return err From a0a84be683b503b020f8187813ea7ac2d3ca0456 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:50:43 +0000 Subject: [PATCH 14/24] fix(coderd): drop whitespace-only MCP display name rejection --- coderd/mcp.go | 15 --------------- coderd/mcp_test.go | 30 ------------------------------ 2 files changed, 45 deletions(-) diff --git a/coderd/mcp.go b/coderd/mcp.go index 071e4e4a2fa..fd2ee6388c1 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -272,14 +272,6 @@ func (api *API) createMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } - // The struct tag accepts whitespace-only names, which trim to "". - if strings.TrimSpace(req.DisplayName) == "" { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Display name is required.", - }) - return - } - if trimmed := strings.TrimSpace(req.OAuth2RevocationURL); trimmed != "" { if err := mcpclient.ValidateRevocationEndpoint(trimmed); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -589,13 +581,6 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) { return } - if req.DisplayName != nil && strings.TrimSpace(*req.DisplayName) == "" { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Display name cannot be empty.", - }) - return - } - // Validated here rather than via a struct tag because an empty // string is a valid value that clears the stored URL. if req.OAuth2RevocationURL != nil { diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index da98e3c8667..b7b82abbe54 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -30,7 +30,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -141,35 +140,6 @@ func TestMCPServerConfigLegacyRoutesRemoved(t *testing.T) { } } -func TestMCPServerConfigDisplayNameValidation(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client := newMCPClient(t) - firstUser := coderdtest.CreateFirstUser(t, client) - - _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ - DisplayName: " ", - Slug: "whitespace-name", - Transport: "streamable_http", - URL: "https://mcp.example.com", - AuthType: "none", - Availability: "default_off", - }) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - - config := createMCPServerConfig(t, client, firstUser.OrganizationID, "display-name-validation", true) - for _, name := range []string{"", " "} { - _, err := client.UpdateMCPServerConfig(ctx, config.ID, codersdk.UpdateMCPServerConfigRequest{ - DisplayName: ptr.Ref(name), - }) - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - } -} - func TestMCPServerConfigsCRUD(t *testing.T) { t.Parallel() From 7dfb53eb65ee9e9d62c9d7b33dfa275e2fd6d681 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:40:08 +0000 Subject: [PATCH 15/24] test(coderd): pass the organization to MCP config SDK calls --- coderd/mcp_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index b7b82abbe54..1d525f99a9b 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -385,7 +385,7 @@ func TestMCPServerConfigsAudit(t *testing.T) { // middleware read and the delete transaction. store.stale.Store(true) mAudit.ResetLogs() - err := client.DeleteMCPServerConfig(ctx, config.ID) + err := client.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID) require.NoError(t, err) logs := mAudit.AuditLogs() @@ -576,7 +576,7 @@ func TestMCPServerConfigsAudit(t *testing.T) { config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete-denied", true) mAudit.ResetLogs() - err := memberClient.DeleteMCPServerConfig(ctx, config.ID) + err := memberClient.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) From 9eed1a3c3070b160e74850153ba7a96df469653b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:25:07 +0000 Subject: [PATCH 16/24] fix(coderd/database): renumber MCP audit migration to 000572 --- ...er_config.down.sql => 000572_audit_mcp_server_config.down.sql} | 0 ...server_config.up.sql => 000572_audit_mcp_server_config.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000571_audit_mcp_server_config.down.sql => 000572_audit_mcp_server_config.down.sql} (100%) rename coderd/database/migrations/{000571_audit_mcp_server_config.up.sql => 000572_audit_mcp_server_config.up.sql} (100%) diff --git a/coderd/database/migrations/000571_audit_mcp_server_config.down.sql b/coderd/database/migrations/000572_audit_mcp_server_config.down.sql similarity index 100% rename from coderd/database/migrations/000571_audit_mcp_server_config.down.sql rename to coderd/database/migrations/000572_audit_mcp_server_config.down.sql diff --git a/coderd/database/migrations/000571_audit_mcp_server_config.up.sql b/coderd/database/migrations/000572_audit_mcp_server_config.up.sql similarity index 100% rename from coderd/database/migrations/000571_audit_mcp_server_config.up.sql rename to coderd/database/migrations/000572_audit_mcp_server_config.up.sql From a3af59dadae0fda5d847e24d013a6f59e310f933 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:19:05 +0000 Subject: [PATCH 17/24] fix(coderd/audit): fall back to the slug for empty MCP display names --- coderd/audit/request.go | 5 ++++- coderd/audit/request_test.go | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 7068283aeb5..d2a5725c378 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -1,6 +1,7 @@ package audit import ( + "cmp" "context" "database/sql" "encoding/json" @@ -155,7 +156,9 @@ func ResourceTarget[T Auditable](tgt T) string { // filter but not the primary resource identifier. return typed.ID.String()[:8] case database.MCPServerConfig: - return typed.DisplayName + // Updates can persist an empty display name; fall back to the + // slug so the audit entry stays identifiable and filterable. + return cmp.Or(typed.DisplayName, typed.Slug) case database.UserSecret: return typed.Name case database.UserSkill: diff --git a/coderd/audit/request_test.go b/coderd/audit/request_test.go index 9bdf4718d3e..192f383d4f6 100644 --- a/coderd/audit/request_test.go +++ b/coderd/audit/request_test.go @@ -45,3 +45,16 @@ func TestResourceTarget_ChatTitleNotLeaked(t *testing.T) { require.NotContains(t, target, chat.Title, "ResourceTarget for Chat must not contain the title; it should use a UUID prefix") } + +func TestResourceTarget_MCPServerConfigSlugFallback(t *testing.T) { + t.Parallel() + + config := database.MCPServerConfig{ + DisplayName: "GitHub MCP", + Slug: "github", + } + require.Equal(t, "GitHub MCP", audit.ResourceTarget(config)) + + config.DisplayName = "" + require.Equal(t, "github", audit.ResourceTarget(config)) +} From bb776b82d27c9468ddacc5b8248c9c7303b59d4f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:29:24 +0000 Subject: [PATCH 18/24] test(coderd): audit MCP config creation only when a row persists --- coderd/mcp_test.go | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index 1d525f99a9b..900d1c85e37 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -419,14 +419,14 @@ func TestMCPServerConfigsAudit(t *testing.T) { require.Empty(t, mAudit.AuditLogs()) }) - t.Run("CreateAuditedWhenDiscoveryUpdateFails", func(t *testing.T) { + t.Run("CreateNotAuditedWhenInsertFails", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) mAudit := audit.NewMock() providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) db, ps := dbtestutil.NewDB(t) - store := &failingMCPServerConfigUpdateStore{Store: db} + store := &failingMCPServerConfigInsertStore{Store: db} client := coderdtest.New(t, &coderdtest.Options{ DeploymentValues: mcpDeploymentValues(t), ChatProviderAPIKeys: &providerKeys, @@ -474,7 +474,6 @@ func TestMCPServerConfigsAudit(t *testing.T) { })) t.Cleanup(mcpServer.Close) - // The inserted row must still be audited when the post-discovery update fails. store.fail.Store(true) mAudit.ResetLogs() _, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{ @@ -492,12 +491,8 @@ func TestMCPServerConfigsAudit(t *testing.T) { configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID) require.NoError(t, err) - require.Len(t, configs, 1) - - logs := mAudit.AuditLogs() - require.Len(t, logs, 1) - require.Equal(t, database.AuditActionCreate, logs[0].Action) - require.Equal(t, configs[0].ID, logs[0].ResourceID) + require.Empty(t, configs) + require.Empty(t, mAudit.AuditLogs()) }) t.Run("DeletedResourceMarked", func(t *testing.T) { @@ -592,19 +587,18 @@ func TestMCPServerConfigsAudit(t *testing.T) { }) } -// failingMCPServerConfigUpdateStore fails config updates once armed, -// outdating the discovery flow's post-insert credential update. -type failingMCPServerConfigUpdateStore struct { +// failingMCPServerConfigInsertStore fails config inserts once armed. +type failingMCPServerConfigInsertStore struct { database.Store fail atomic.Bool } -func (s *failingMCPServerConfigUpdateStore) UpdateMCPServerConfig(ctx context.Context, arg database.UpdateMCPServerConfigParams) (database.MCPServerConfig, error) { +func (s *failingMCPServerConfigInsertStore) InsertMCPServerConfig(ctx context.Context, arg database.InsertMCPServerConfigParams) (database.MCPServerConfig, error) { if s.fail.Load() { - return database.MCPServerConfig{}, xerrors.New("injected update failure") + return database.MCPServerConfig{}, xerrors.New("injected insert failure") } - return s.Store.UpdateMCPServerConfig(ctx, arg) + return s.Store.InsertMCPServerConfig(ctx, arg) } // staleMCPServerConfigReadStore corrupts plain config reads once armed, From e39972f9f860deedae268a4c58d3c1aa42457eea Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:42:13 +0000 Subject: [PATCH 19/24] fix(coderd/audit): retain identifiable MCP audit targets --- coderd/audit/request.go | 6 +++--- coderd/audit/request_test.go | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/coderd/audit/request.go b/coderd/audit/request.go index d2a5725c378..944c0a891da 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -156,9 +156,9 @@ func ResourceTarget[T Auditable](tgt T) string { // filter but not the primary resource identifier. return typed.ID.String()[:8] case database.MCPServerConfig: - // Updates can persist an empty display name; fall back to the - // slug so the audit entry stays identifiable and filterable. - return cmp.Or(typed.DisplayName, typed.Slug) + // Updates can persist an empty display name; fall back to the slug, or + // the ID if both are empty, so the audit entry stays identifiable. + return cmp.Or(typed.DisplayName, typed.Slug, typed.ID.String()) case database.UserSecret: return typed.Name case database.UserSkill: diff --git a/coderd/audit/request_test.go b/coderd/audit/request_test.go index 192f383d4f6..44834e0221e 100644 --- a/coderd/audit/request_test.go +++ b/coderd/audit/request_test.go @@ -50,6 +50,7 @@ func TestResourceTarget_MCPServerConfigSlugFallback(t *testing.T) { t.Parallel() config := database.MCPServerConfig{ + ID: uuid.UUID{1}, DisplayName: "GitHub MCP", Slug: "github", } @@ -57,4 +58,7 @@ func TestResourceTarget_MCPServerConfigSlugFallback(t *testing.T) { config.DisplayName = "" require.Equal(t, "github", audit.ResourceTarget(config)) + + config.Slug = "" + require.Equal(t, config.ID.String(), audit.ResourceTarget(config)) } From d3d9854e07872464b6c9e29551015fe575d10499 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:11:28 +0000 Subject: [PATCH 20/24] fix(enterprise/audit): canonicalize empty MCP fields --- enterprise/audit/diff.go | 25 ++++++++++++++++++++++++- enterprise/audit/diff_internal_test.go | 14 ++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/enterprise/audit/diff.go b/enterprise/audit/diff.go index 8196238ecc8..970767184a6 100644 --- a/enterprise/audit/diff.go +++ b/enterprise/audit/diff.go @@ -73,7 +73,7 @@ func diffValues(left, right any, table Table) audit.Map { leftI, rightI = leftF.Interface(), rightF.Interface() } - if !reflect.DeepEqual(leftI, rightI) { + if !auditValuesEqual(rightT, diffName, leftI, rightI) { switch atype { case ActionTrack: baseDiff[diffName] = audit.OldNew{Old: leftI, New: rightI} @@ -90,6 +90,29 @@ func diffValues(left, right any, table Table) audit.Map { return baseDiff } +func auditValuesEqual(resourceType reflect.Type, fieldName string, left, right any) bool { + if reflect.DeepEqual(left, right) { + return true + } + if resourceType != reflect.TypeFor[database.MCPServerConfig]() { + return false + } + + switch fieldName { + case "custom_headers": + leftString, leftOK := left.(string) + rightString, rightOK := right.(string) + return leftOK && rightOK && (leftString == "" && rightString == "{}" || + leftString == "{}" && rightString == "") + case "tool_allow_list", "tool_deny_list": + leftStrings, leftOK := left.([]string) + rightStrings, rightOK := right.([]string) + return leftOK && rightOK && len(leftStrings) == 0 && len(rightStrings) == 0 + default: + return false + } +} + // convertDiffType converts external struct types to primitive types. // //nolint:forcetypeassert diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index f1e14d91ce9..9c54d05814e 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -562,6 +562,16 @@ func Test_diff(t *testing.T) { }) runDiffTests(t, []diffTest{ + { + name: "CreateEmptyStoredValues", + left: audit.Empty[database.MCPServerConfig](), + right: database.MCPServerConfig{ + CustomHeaders: "{}", + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }, + exp: audit.Map{}, + }, { name: "Create", left: audit.Empty[database.MCPServerConfig](), @@ -574,6 +584,8 @@ func Test_diff(t *testing.T) { APIKeyHeader: "X-Api-Key", APIKeyValue: "plaintext-api-key", CustomHeaders: `{"Authorization":"Bearer plaintext-header"}`, + ToolAllowList: []string{"issues"}, + ToolDenyList: []string{"delete_repository"}, OAuth2ClientSecret: "plaintext-oauth-secret", Enabled: true, CreatedBy: uuid.NullUUID{UUID: uuid.UUID{2}, Valid: true}, @@ -588,6 +600,8 @@ func Test_diff(t *testing.T) { "api_key_header": audit.OldNew{Old: "", New: "X-Api-Key"}, "api_key_value": audit.OldNew{Old: "", New: "", Secret: true}, "custom_headers": audit.OldNew{Old: "", New: "", Secret: true}, + "tool_allow_list": audit.OldNew{Old: []string(nil), New: []string{"issues"}}, + "tool_deny_list": audit.OldNew{Old: []string(nil), New: []string{"delete_repository"}}, "oauth2_client_secret": audit.OldNew{Old: "", New: "", Secret: true}, "enabled": audit.OldNew{Old: false, New: true}, "created_by": audit.OldNew{Old: "null", New: uuid.UUID{2}.String()}, From f48ed8f6f0148cdbc13a2870f475858a44198f63 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:14:17 +0000 Subject: [PATCH 21/24] test(enterprise/audit): cover MCP header transitions --- enterprise/audit/diff_internal_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/enterprise/audit/diff_internal_test.go b/enterprise/audit/diff_internal_test.go index 9c54d05814e..ae5dd2ea63f 100644 --- a/enterprise/audit/diff_internal_test.go +++ b/enterprise/audit/diff_internal_test.go @@ -608,6 +608,30 @@ func Test_diff(t *testing.T) { "updated_by": audit.OldNew{Old: "null", New: uuid.UUID{2}.String()}, }, }, + { + name: "CustomHeadersAdded", + left: database.MCPServerConfig{ + CustomHeaders: "{}", + }, + right: database.MCPServerConfig{ + CustomHeaders: `{"Authorization":"Bearer plaintext-header"}`, + }, + exp: audit.Map{ + "custom_headers": audit.OldNew{Old: "", New: "", Secret: true}, + }, + }, + { + name: "CustomHeadersRemoved", + left: database.MCPServerConfig{ + CustomHeaders: `{"Authorization":"Bearer plaintext-header"}`, + }, + right: database.MCPServerConfig{ + CustomHeaders: "{}", + }, + exp: audit.Map{ + "custom_headers": audit.OldNew{Old: "", New: "", Secret: true}, + }, + }, { name: "SecretRotationRedacted", left: database.MCPServerConfig{ From 146d8c815736c8c02d12b828a19ac8648fe423f2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:46:50 +0000 Subject: [PATCH 22/24] fix(coderd): gate MCP audit links on management access --- coderd/audit.go | 14 ++++++++++++++ coderd/audit_test.go | 16 +++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/coderd/audit.go b/coderd/audit.go index 223ee772dda..3ec580a006f 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" ) @@ -617,6 +618,19 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit // not username-scoped in the URL like workspaces or tasks. return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID) case database.ResourceTypeMCPServerConfig: + config, err := api.Database.GetMCPServerConfigByID(ctx, alog.AuditLog.ResourceID) + if err != nil { + return "" + } + actor, ok := dbauthz.ActorFromContext(ctx) + if !ok { + return "" + } + if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, config.RBACObject()); err != nil { + if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionDelete, config.RBACObject()); err != nil { + return "" + } + } return fmt.Sprintf("/ai/settings/mcp-servers/%s", alog.AuditLog.ResourceID) case database.ResourceTypeUserSecret: // TODO(PLAT-102): point at the user secrets management page once diff --git a/coderd/audit_test.go b/coderd/audit_test.go index bf33c6f8120..b5923105949 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -151,6 +151,7 @@ func TestAuditLogs(t *testing.T) { db, ps := dbtestutil.NewDB(t) client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) user := coderdtest.CreateFirstUser(t, client) + auditor, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleAuditor()) config := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: user.OrganizationID, @@ -163,14 +164,23 @@ func TestAuditLogs(t *testing.T) { }) require.NoError(t, err) - auditLogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{ + auditorLogs, err := auditor.AuditLogs(ctx, codersdk.AuditLogsRequest{ + Pagination: codersdk.Pagination{ + Limit: 1, + }, + }) + require.NoError(t, err) + require.Len(t, auditorLogs.AuditLogs, 1) + require.Empty(t, auditorLogs.AuditLogs[0].ResourceLink) + + ownerLogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{ Pagination: codersdk.Pagination{ Limit: 1, }, }) require.NoError(t, err) - require.Len(t, auditLogs.AuditLogs, 1) - require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s", config.ID), auditLogs.AuditLogs[0].ResourceLink) + require.Len(t, ownerLogs.AuditLogs, 1) + require.Equal(t, fmt.Sprintf("/ai/settings/mcp-servers/%s", config.ID), ownerLogs.AuditLogs[0].ResourceLink) }) t.Run("Organization", func(t *testing.T) { From a03f5ff5fb76158840ab03a5c88f883590ffb148 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:46:53 +0000 Subject: [PATCH 23/24] fix(coderd): match MCP audit links to settings page access --- coderd/audit.go | 13 +++++-------- coderd/audit_test.go | 12 ++++++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/coderd/audit.go b/coderd/audit.go index 3ec580a006f..4188478cf52 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -22,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" @@ -618,18 +619,14 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit // not username-scoped in the URL like workspaces or tasks. return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID) case database.ResourceTypeMCPServerConfig: - config, err := api.Database.GetMCPServerConfigByID(ctx, alog.AuditLog.ResourceID) - if err != nil { - return "" - } actor, ok := dbauthz.ActorFromContext(ctx) if !ok { return "" } - if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, config.RBACObject()); err != nil { - if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionDelete, config.RBACObject()); err != nil { - return "" - } + // The MCP settings page admits only deployment-config managers, + // so emit the link only for callers the page will accept. + if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return "" } return fmt.Sprintf("/ai/settings/mcp-servers/%s", alog.AuditLog.ResourceID) case database.ResourceTypeUserSecret: diff --git a/coderd/audit_test.go b/coderd/audit_test.go index b5923105949..94d7002ea6a 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -152,6 +152,7 @@ func TestAuditLogs(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) user := coderdtest.CreateFirstUser(t, client) auditor, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleAuditor()) + orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.ScopedRoleOrgAdmin(user.OrganizationID)) config := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ OrganizationID: user.OrganizationID, @@ -173,6 +174,17 @@ func TestAuditLogs(t *testing.T) { require.Len(t, auditorLogs.AuditLogs, 1) require.Empty(t, auditorLogs.AuditLogs[0].ResourceLink) + // Organization admins hold MCP permissions but not the + // deployment-config access the settings page requires. + orgAdminLogs, err := orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{ + Pagination: codersdk.Pagination{ + Limit: 1, + }, + }) + require.NoError(t, err) + require.Len(t, orgAdminLogs.AuditLogs, 1) + require.Empty(t, orgAdminLogs.AuditLogs[0].ResourceLink) + ownerLogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{ Pagination: codersdk.Pagination{ Limit: 1, From b0ffee04e1922babb9d62a56c096b0f5fabb37a9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:00:23 +0000 Subject: [PATCH 24/24] fix(coderd/database): renumber MCP audit migration to 000575 --- ...onfig.down.sql => 000575_audit_mcp_server_config.down.sql} | 0 ...er_config.up.sql => 000575_audit_mcp_server_config.up.sql} | 0 docs/reference/api/schemas.md | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) rename coderd/database/migrations/{000572_audit_mcp_server_config.down.sql => 000575_audit_mcp_server_config.down.sql} (100%) rename coderd/database/migrations/{000572_audit_mcp_server_config.up.sql => 000575_audit_mcp_server_config.up.sql} (100%) diff --git a/coderd/database/migrations/000572_audit_mcp_server_config.down.sql b/coderd/database/migrations/000575_audit_mcp_server_config.down.sql similarity index 100% rename from coderd/database/migrations/000572_audit_mcp_server_config.down.sql rename to coderd/database/migrations/000575_audit_mcp_server_config.down.sql diff --git a/coderd/database/migrations/000572_audit_mcp_server_config.up.sql b/coderd/database/migrations/000575_audit_mcp_server_config.up.sql similarity index 100% rename from coderd/database/migrations/000572_audit_mcp_server_config.up.sql rename to coderd/database/migrations/000575_audit_mcp_server_config.up.sql diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 3a2949df159..2ca7cad6d52 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -11704,8 +11704,8 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `chat_instruction_settings`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `mcp_server_config`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `oauth2_provider_settings`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_ai_budget_override`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | ## codersdk.Response