Files
coder/codersdk/audit.go
T
Michael Suchacz 299e72ad30 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 -->
2026-08-19 19:11:52 +00:00

306 lines
10 KiB
Go

package codersdk
import (
"context"
"encoding/json"
"net/http"
"net/netip"
"strings"
"time"
"github.com/google/uuid"
)
type ResourceType string
const (
ResourceTypeTemplate ResourceType = "template"
ResourceTypeTemplateVersion ResourceType = "template_version"
ResourceTypeUser ResourceType = "user"
ResourceTypeWorkspace ResourceType = "workspace"
ResourceTypeWorkspaceBuild ResourceType = "workspace_build"
ResourceTypeGitSSHKey ResourceType = "git_ssh_key"
ResourceTypeAPIKey ResourceType = "api_key"
ResourceTypeGroup ResourceType = "group"
ResourceTypeLicense ResourceType = "license"
ResourceTypeConvertLogin ResourceType = "convert_login"
ResourceTypeHealthSettings ResourceType = "health_settings"
ResourceTypeNotificationsSettings ResourceType = "notifications_settings"
ResourceTypePrebuildsSettings ResourceType = "prebuilds_settings"
ResourceTypeOAuth2ProviderSettings ResourceType = "oauth2_provider_settings"
ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy"
ResourceTypeOrganization ResourceType = "organization"
ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app"
// nolint:gosec // This is not a secret.
ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret"
ResourceTypeCustomRole ResourceType = "custom_role"
ResourceTypeOrganizationMember ResourceType = "organization_member"
ResourceTypeNotificationTemplate ResourceType = "notification_template"
ResourceTypeIdpSyncSettingsOrganization ResourceType = "idp_sync_settings_organization"
ResourceTypeIdpSyncSettingsGroup ResourceType = "idp_sync_settings_group"
ResourceTypeIdpSyncSettingsRole ResourceType = "idp_sync_settings_role"
// Deprecated: Workspace Agent connections are now included in the
// connection log.
ResourceTypeWorkspaceAgent ResourceType = "workspace_agent"
// Deprecated: Workspace App connections are now included in the
// connection log.
ResourceTypeWorkspaceApp ResourceType = "workspace_app"
ResourceTypeTask ResourceType = "task"
ResourceTypeAISeat ResourceType = "ai_seat"
ResourceTypeAIProvider ResourceType = "ai_provider"
ResourceTypeAIProviderKey ResourceType = "ai_provider_key"
ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key"
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"
)
func (r ResourceType) FriendlyString() string {
switch r {
case ResourceTypeTemplate:
return "template"
case ResourceTypeTemplateVersion:
return "template version"
case ResourceTypeUser:
return "user"
case ResourceTypeWorkspace:
return "workspace"
case ResourceTypeWorkspaceBuild:
// workspace builds have a unique friendly string
// see coderd/audit.go:298 for explanation
return "workspace"
case ResourceTypeGitSSHKey:
return "git ssh key"
case ResourceTypeAPIKey:
return "token"
case ResourceTypeGroup:
return "group"
case ResourceTypeLicense:
return "license"
case ResourceTypeConvertLogin:
return "login type conversion"
case ResourceTypeWorkspaceProxy:
return "workspace proxy"
case ResourceTypeOrganization:
return "organization"
case ResourceTypeHealthSettings:
return "health_settings"
case ResourceTypeNotificationsSettings:
return "notifications_settings"
case ResourceTypePrebuildsSettings:
return "prebuilds_settings"
case ResourceTypeOAuth2ProviderSettings:
return "oauth2 provider settings"
case ResourceTypeOAuth2ProviderApp:
return "oauth2 app"
case ResourceTypeOAuth2ProviderAppSecret:
return "oauth2 app secret"
case ResourceTypeCustomRole:
return "custom role"
case ResourceTypeOrganizationMember:
return "organization member"
case ResourceTypeNotificationTemplate:
return "notification template"
case ResourceTypeIdpSyncSettingsOrganization:
return "settings"
case ResourceTypeIdpSyncSettingsGroup:
return "settings"
case ResourceTypeIdpSyncSettingsRole:
return "settings"
case ResourceTypeWorkspaceAgent:
return "workspace agent"
case ResourceTypeWorkspaceApp:
return "workspace app"
case ResourceTypeTask:
return "task"
case ResourceTypeAISeat:
return "ai seat"
case ResourceTypeAIProvider:
return "ai provider"
case ResourceTypeAIProviderKey:
return "ai provider key"
case ResourceTypeAIGatewayKey:
return "ai gateway key"
case ResourceTypeGroupAIBudget:
return "group ai budget"
case ResourceTypeUserAIBudgetOverride:
return "user ai budget override"
case ResourceTypeChat:
return "chat"
case ResourceTypeMCPServerConfig:
return "mcp server config"
case ResourceTypeUserSecret:
return "user secret"
case ResourceTypeUserSkill:
return "user skill"
case ResourceTypeChatInstructionSettings:
return "chat instruction settings"
default:
return "unknown"
}
}
type AuditAction string
const (
AuditActionCreate AuditAction = "create"
AuditActionWrite AuditAction = "write"
AuditActionDelete AuditAction = "delete"
AuditActionStart AuditAction = "start"
AuditActionStop AuditAction = "stop"
AuditActionLogin AuditAction = "login"
AuditActionLogout AuditAction = "logout"
AuditActionRegister AuditAction = "register"
AuditActionRequestPasswordReset AuditAction = "request_password_reset"
// Deprecated: Workspace connections are now included in the
// connection log.
AuditActionConnect AuditAction = "connect"
// Deprecated: Workspace disconnections are now included in the
// connection log.
AuditActionDisconnect AuditAction = "disconnect"
// Deprecated: Workspace App connections are now included in the
// connection log.
AuditActionOpen AuditAction = "open"
// Deprecated: This action is unused.
AuditActionClose AuditAction = "close"
)
func (a AuditAction) Friendly() string {
switch a {
case AuditActionCreate:
return "created"
case AuditActionWrite:
return "updated"
case AuditActionDelete:
return "deleted"
case AuditActionStart:
return "started"
case AuditActionStop:
return "stopped"
case AuditActionLogin:
return "logged in"
case AuditActionLogout:
return "logged out"
case AuditActionRegister:
return "registered"
case AuditActionRequestPasswordReset:
return "password reset requested"
case AuditActionConnect:
return "connected"
case AuditActionDisconnect:
return "disconnected"
case AuditActionOpen:
return "opened"
case AuditActionClose:
return "closed"
default:
return "unknown"
}
}
type AuditDiff map[string]AuditDiffField
type AuditDiffField struct {
Old any `json:"old,omitempty"`
New any `json:"new,omitempty"`
Secret bool `json:"secret"`
}
type AuditLog struct {
ID uuid.UUID `json:"id" format:"uuid"`
RequestID uuid.UUID `json:"request_id" format:"uuid"`
Time time.Time `json:"time" format:"date-time"`
IP netip.Addr `json:"ip"`
UserAgent string `json:"user_agent"`
ResourceType ResourceType `json:"resource_type"`
ResourceID uuid.UUID `json:"resource_id" format:"uuid"`
// ResourceTarget is the name of the resource.
ResourceTarget string `json:"resource_target"`
ResourceIcon string `json:"resource_icon"`
Action AuditAction `json:"action"`
Diff AuditDiff `json:"diff"`
StatusCode int32 `json:"status_code"`
AdditionalFields json.RawMessage `json:"additional_fields" swaggertype:"object"`
Description string `json:"description"`
ResourceLink string `json:"resource_link"`
IsDeleted bool `json:"is_deleted"`
// Deprecated: Use 'organization.id' instead.
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
Organization *MinimalOrganization `json:"organization,omitempty"`
User *User `json:"user"`
}
type AuditLogsRequest struct {
SearchQuery string `json:"q,omitempty"`
Pagination
}
type AuditLogResponse struct {
AuditLogs []AuditLog `json:"audit_logs"`
Count int64 `json:"count"`
CountCap int64 `json:"count_cap"`
}
type CreateTestAuditLogRequest struct {
Action AuditAction `json:"action,omitempty" enums:"create,write,delete,start,stop"`
ResourceType ResourceType `json:"resource_type,omitempty" enums:"template,template_version,user,workspace,workspace_build,git_ssh_key,auditable_group"`
ResourceID uuid.UUID `json:"resource_id,omitempty" format:"uuid"`
AdditionalFields json.RawMessage `json:"additional_fields,omitempty"`
Time time.Time `json:"time,omitempty" format:"date-time"`
BuildReason BuildReason `json:"build_reason,omitempty" enums:"autostart,autostop,initiator"`
OrganizationID uuid.UUID `json:"organization_id,omitempty" format:"uuid"`
RequestID uuid.UUID `json:"request_id,omitempty" format:"uuid"`
}
// AuditLogs retrieves audit logs from the given page.
func (c *Client) AuditLogs(ctx context.Context, req AuditLogsRequest) (AuditLogResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/audit", nil, req.Pagination.asRequestOption(), func(r *http.Request) {
q := r.URL.Query()
var params []string
if req.SearchQuery != "" {
params = append(params, req.SearchQuery)
}
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
})
if err != nil {
return AuditLogResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AuditLogResponse{}, ReadBodyAsError(res)
}
var logRes AuditLogResponse
err = ReadBodyAsJSON(res, &logRes)
if err != nil {
return AuditLogResponse{}, err
}
return logRes, nil
}
// CreateTestAuditLog creates a fake audit log. Only owners of the organization
// can perform this action. It's used for testing purposes.
func (c *Client) CreateTestAuditLog(ctx context.Context, req CreateTestAuditLogRequest) error {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/audit/testgenerate", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return err
}
return nil
}