mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: audit MCP server config changes (#27943)
Adds enterprise audit logging for MCP server config create, update, and
delete, with strict secret redaction. MCP configs hold credentials
(OAuth2 client secrets, API keys, custom headers), so admin changes to
them need an audit trail.
## Summary
- `enterprise/audit/table.go` gains an `MCPServerConfig` entry
enumerating every column: `oauth2_client_secret`, `api_key_value`, and
`custom_headers` are `ActionSecret` (never appear in diffs); dbcrypt
`*_key_id` bookkeeping, IDs, and timestamps are ignored; the remaining
config fields, including the endpoint URL fields, are tracked so
auditors can see which endpoints a config points at.
- Type registration in `coderd/audit` (diff, request, resource target
with org attribution), `codersdk/audit.go`, and a `resource_type` enum
migration.
- Handlers wire `audit.InitRequest`: create records `New`; update and
delete record `Old` from the param middleware before the
write-authorization check, so a readable-but-not-writable caller
produces an audited 403 while read-denied callers stay concealed as
unaudited 404s.
- Tests: create/update/delete audit entries, write-denied and
delete-denied 403 auditing, cross-org concealment producing zero
entries, and a serializer-level regression test proving none of the
three secret classes can reach a serialized diff.
- Review round: MCP config audit entries link to
`/ai/settings/mcp-servers/{id}`, audit table comments are trimmed per
review, and a fault-injection test pins that a config row surviving a
failed post-discovery credential update still gets its creation audit
entry.
Stacked on #27942 (org-scoped MCP configs). Part of the MCP
org-separation stack.
Closes https://linear.app/codercom/issue/CODAGT-717
UAT: verified on a trial-licensed dogfood instance: audit entries for
the full CRUD lifecycle with correct actor/org/target, redacted secrets
in the update and OAuth2 create diffs, and a full plaintext scan of the
audit dump finding zero secret leaks.
> Mux (AI agent) authored this PR on Mike's behalf.
<!-- mux-attribution: model=claude-fable-5 thinking=high -->
This commit is contained in:
Generated
+2
@@ -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"
|
||||
|
||||
Generated
+2
@@ -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"
|
||||
|
||||
@@ -22,6 +22,8 @@ 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"
|
||||
)
|
||||
@@ -501,6 +503,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) {
|
||||
@@ -604,6 +618,17 @@ 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:
|
||||
actor, ok := dbauthz.ActorFromContext(ctx)
|
||||
if !ok {
|
||||
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:
|
||||
// TODO(PLAT-102): point at the user secrets management page once
|
||||
// it ships. Until then, the audit row links nowhere.
|
||||
|
||||
@@ -39,6 +39,7 @@ type Auditable interface {
|
||||
database.AIProviderKey |
|
||||
database.AIGatewayKey |
|
||||
database.Chat |
|
||||
database.MCPServerConfig |
|
||||
database.AuditableGroupAIBudget |
|
||||
database.AuditableUserAIBudgetOverride |
|
||||
database.UserSecret |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -154,6 +155,10 @@ 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:
|
||||
// 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:
|
||||
@@ -257,6 +262,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 +342,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 +434,9 @@ func ResourceRequiresOrgID[T Auditable]() bool {
|
||||
// Chats always have a non-null organization_id (since
|
||||
// migration 000467).
|
||||
return true
|
||||
case database.MCPServerConfig:
|
||||
// MCP server configs always carry a non-null organization_id.
|
||||
return true
|
||||
case database.UserSecret:
|
||||
// User secrets are global to the user across organizations.
|
||||
return false
|
||||
|
||||
@@ -45,3 +45,20 @@ 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{
|
||||
ID: uuid.UUID{1},
|
||||
DisplayName: "GitHub MCP",
|
||||
Slug: "github",
|
||||
}
|
||||
require.Equal(t, "GitHub MCP", audit.ResourceTarget(config))
|
||||
|
||||
config.DisplayName = ""
|
||||
require.Equal(t, "github", audit.ResourceTarget(config))
|
||||
|
||||
config.Slug = ""
|
||||
require.Equal(t, config.ID.String(), audit.ResourceTarget(config))
|
||||
}
|
||||
|
||||
@@ -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,57 @@ 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)
|
||||
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,
|
||||
})
|
||||
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
|
||||
Action: codersdk.AuditActionCreate,
|
||||
ResourceType: codersdk.ResourceTypeMCPServerConfig,
|
||||
ResourceID: config.ID,
|
||||
OrganizationID: user.OrganizationID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Generated
+2
-1
@@ -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 (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op, enum values can't be dropped.
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE resource_type
|
||||
ADD VALUE IF NOT EXISTS 'mcp_server_config';
|
||||
Generated
+4
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+64
-4
@@ -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
|
||||
@@ -593,14 +620,15 @@ 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.
|
||||
// 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
|
||||
}
|
||||
existing = current
|
||||
aReq.Old = current
|
||||
|
||||
touchesUserOIDC := existing.AuthType == "user_oidc" ||
|
||||
(req.AuthType != nil && *req.AuthType == "user_oidc")
|
||||
@@ -873,6 +901,8 @@ func (api *API) updateMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
aReq.New = updated
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, convertMCPServerConfig(updated))
|
||||
}
|
||||
|
||||
@@ -888,12 +918,42 @@ 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
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
@@ -21,11 +21,14 @@ 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"
|
||||
"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"
|
||||
@@ -261,6 +264,360 @@ 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)
|
||||
})
|
||||
|
||||
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, firstUser.OrganizationID, 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()
|
||||
|
||||
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("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 := &failingMCPServerConfigInsertStore{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)
|
||||
|
||||
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.Empty(t, configs)
|
||||
require.Empty(t, mAudit.AuditLogs())
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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, firstUser.OrganizationID, 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)
|
||||
})
|
||||
}
|
||||
|
||||
// failingMCPServerConfigInsertStore fails config inserts once armed.
|
||||
type failingMCPServerConfigInsertStore struct {
|
||||
database.Store
|
||||
|
||||
fail atomic.Bool
|
||||
}
|
||||
|
||||
func (s *failingMCPServerConfigInsertStore) InsertMCPServerConfig(ctx context.Context, arg database.InsertMCPServerConfigParams) (database.MCPServerConfig, error) {
|
||||
if s.fail.Load() {
|
||||
return database.MCPServerConfig{}, xerrors.New("injected insert failure")
|
||||
}
|
||||
return s.Store.InsertMCPServerConfig(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.
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user