Files
coder/enterprise/audit/diff.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

273 lines
7.2 KiB
Go

package audit
import (
"database/sql"
"fmt"
"reflect"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/util/ptr"
)
func structName(t reflect.Type) string {
return t.PkgPath() + "." + t.Name()
}
func diffValues(left, right any, table Table) audit.Map {
var (
baseDiff = audit.Map{}
rightT = reflect.TypeOf(right)
leftV = reflect.ValueOf(left)
rightV = reflect.ValueOf(right)
diffKey = table[structName(rightT)]
)
if diffKey == nil {
panic(fmt.Sprintf("dev error: type %q (type %T) attempted audit but not auditable", rightT.Name(), right))
}
// allFields contains all top level fields of the struct.
allFields, err := flattenStructFields(leftV, rightV)
if err != nil {
// This should never happen. Only structs should be flattened. If an
// error occurs, an unsupported or non-struct type was passed in.
panic(fmt.Sprintf("dev error: failed to flatten struct fields: %v", err))
}
for _, field := range allFields {
var (
leftF = field.LeftF
rightF = field.RightF
leftI = leftF.Interface()
rightI = rightF.Interface()
)
diffName := field.FieldType.Tag.Get("json")
diffName = strings.TrimSuffix(diffName, ",omitempty")
atype, ok := diffKey[diffName]
if !ok {
panic(fmt.Sprintf("dev error: field %q lacks audit information", diffName))
}
if atype == ActionIgnore {
continue
}
// coerce struct types that would produce bad diffs.
if leftI, rightI, ok = convertDiffType(leftI, rightI); ok {
leftF, rightF = reflect.ValueOf(leftI), reflect.ValueOf(rightI)
}
// If the field is a pointer, dereference it. Nil pointers are coerced
// to the zero value of their underlying type.
if leftF.Kind() == reflect.Ptr && rightF.Kind() == reflect.Ptr {
leftF, rightF = derefPointer(leftF), derefPointer(rightF)
leftI, rightI = leftF.Interface(), rightF.Interface()
}
if !auditValuesEqual(rightT, diffName, leftI, rightI) {
switch atype {
case ActionTrack:
baseDiff[diffName] = audit.OldNew{Old: leftI, New: rightI}
case ActionSecret:
baseDiff[diffName] = audit.OldNew{
Old: reflect.Zero(rightF.Type()).Interface(),
New: reflect.Zero(rightF.Type()).Interface(),
Secret: true,
}
}
}
}
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
func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
switch typedLeft := left.(type) {
case uuid.UUID:
typedRight := right.(uuid.UUID)
// Automatically coerce Nil UUIDs to empty strings.
outLeft := typedLeft.String()
if typedLeft == uuid.Nil {
outLeft = ""
}
outRight := typedRight.String()
if typedRight == uuid.Nil {
outRight = ""
}
return outLeft, outRight, true
case uuid.NullUUID:
leftStr, _ := typedLeft.MarshalText()
rightStr, _ := right.(uuid.NullUUID).MarshalText()
return string(leftStr), string(rightStr), true
case sql.NullString:
leftStr := typedLeft.String
if !typedLeft.Valid {
leftStr = "null"
}
rightStr := right.(sql.NullString).String
if !right.(sql.NullString).Valid {
rightStr = "null"
}
return leftStr, rightStr, true
case sql.NullInt64:
var leftInt64Ptr *int64
var rightInt64Ptr *int64
if !typedLeft.Valid {
leftInt64Ptr = nil
} else {
leftInt64Ptr = ptr.Ref(typedLeft.Int64)
}
rightInt64Ptr = ptr.Ref(right.(sql.NullInt64).Int64)
if !right.(sql.NullInt64).Valid {
rightInt64Ptr = nil
}
return leftInt64Ptr, rightInt64Ptr, true
case database.NullNotificationMethod:
vl, vr := string(typedLeft.NotificationMethod), ""
if val, ok := right.(database.NullNotificationMethod); ok {
vr = string(val.NotificationMethod)
}
return vl, vr, true
case database.TemplateACL:
return fmt.Sprintf("%+v", left), fmt.Sprintf("%+v", right), true
case database.CustomRolePermissions:
// String representation is much easier to visually inspect
leftArr := make([]string, 0)
rightArr := make([]string, 0)
for _, p := range typedLeft {
leftArr = append(leftArr, p.String())
}
for _, p := range right.(database.CustomRolePermissions) {
rightArr = append(rightArr, p.String())
}
return leftArr, rightArr, true
default:
return left, right, false
}
}
// fieldDiff has all the required information to return an audit diff for a
// given field.
type fieldDiff struct {
FieldType reflect.StructField
LeftF reflect.Value
RightF reflect.Value
}
// flattenStructFields will return all top level fields for a given structure.
// Only anonymously embedded structs will be recursively flattened such that their
// fields are returned as top level fields. Named nested structs will be returned
// as a single field.
// Conflicting field names need to be handled by the caller.
func flattenStructFields(leftV, rightV reflect.Value) ([]fieldDiff, error) {
// Dereference pointers if the field is a pointer field.
if leftV.Kind() == reflect.Ptr {
leftV = derefPointer(leftV)
rightV = derefPointer(rightV)
}
if leftV.Kind() != reflect.Struct {
return nil, xerrors.Errorf("%q is not a struct, kind=%s", leftV.String(), leftV.Kind())
}
var allFields []fieldDiff
rightT := rightV.Type()
// Loop through all top level fields of the struct.
for i := 0; i < rightT.NumField(); i++ {
if !rightT.Field(i).IsExported() {
continue
}
var (
leftF = leftV.Field(i)
rightF = rightV.Field(i)
)
if rightT.Field(i).Anonymous {
// Anonymous fields are recursively flattened.
anonFields, err := flattenStructFields(leftF, rightF)
if err != nil {
return nil, xerrors.Errorf("flatten anonymous field %q: %w", rightT.Field(i).Name, err)
}
allFields = append(allFields, anonFields...)
continue
}
// Single fields append as is.
allFields = append(allFields, fieldDiff{
LeftF: leftF,
RightF: rightF,
FieldType: rightT.Field(i),
})
}
return allFields, nil
}
// derefPointer deferences a reflect.Value that is a pointer to its underlying
// value. It dereferences recursively until it finds a non-pointer value. If the
// pointer is nil, it will be coerced to the zero value of the underlying type.
func derefPointer(ref reflect.Value) reflect.Value {
if !ref.IsNil() {
// Grab the value the pointer references.
ref = ref.Elem()
} else {
// Coerce nil ptrs to zero'd values of their underlying type.
ref = reflect.Zero(ref.Type().Elem())
}
// Recursively deref nested pointers.
if ref.Kind() == reflect.Ptr {
return derefPointer(ref)
}
return ref
}