diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f2b1a..b2ed01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to mcp-audit are documented in this file. - HTTP security principals with explicit local identity and optional static bearer authentication backed by constant-time token comparison. +- Principal-aware policy selectors for subject, issuer, role, scope, method, + and operation name, plus minimal principal evidence in JSONL and SQLite. - HTTP proxy request-body and header limits, complete server timeouts, optional browser Origin validation, Host validation for DNS-rebinding protection, and `mcp_audit_http_request_rejections_total` metrics. diff --git a/README.md b/README.md index 9ba9337..66950b8 100644 --- a/README.md +++ b/README.md @@ -178,12 +178,12 @@ Prometheus metrics are available at `http://localhost:9091/metrics` by default. | `audit.rotation.interval` | empty | Optional time-based JSONL rotation interval: `hourly` or `daily`. Empty disables time-based rotation. | | `audit.rotation.max_age_days` | `0` | Delete JSONL archives whose filename rotation timestamp is older than this many days. `0` disables age retention. | | `middleware.rate_limit.enabled` | `true` | Enable per-client, per-tool token buckets. | -| `middleware.rate_limit.requests_per_minute` | `60` | Allowed requests per minute per `(client_id, tool_name)`. | +| `middleware.rate_limit.requests_per_minute` | `60` | Allowed requests per minute per authenticated `(client_id, tool_name)`. | | `middleware.redact.enabled` | `true` | Enable JSON key-based PII redaction. | | `middleware.redact.patterns` | sensitive keys | Case-insensitive key fragments to redact. | | `policy.enabled` | `false` | Enable synchronous allow/deny policy checks for `tools/call`. | | `policy.default_action` | `allow` | Fallback action when no policy rule matches: `allow` or `deny`. | -| `policy.rules` | empty | Ordered first-match allow/deny rules for tool calls. | +| `policy.rules` | empty | Ordered first-match allow/deny rules. Existing `client_id`, `server_id`, and `tool_name` selectors remain valid; optional principal selectors are `subject`, `issuer`, `role`, and `scope`, with generic operation selectors `method` and `name`. | | `dashboard.enabled` | `true` | Serve the dashboard. | | `dashboard.bind_address` | `127.0.0.1` | Dashboard listen address. Set explicitly, for example to `0.0.0.0`, only when the dashboard is protected by network controls or auth. | | `dashboard.port` | `9090` | Dashboard listen port. | diff --git a/STABILITY.md b/STABILITY.md index dded563..04a55f7 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -25,6 +25,8 @@ The following surfaces are covered by the stability policy starting at `v1.0.0`: - `proxy.forward_headers` is part of the stable configuration surface. Forwarded headers are passed verbatim to the trusted upstream HTTP MCP server, but HTTP headers are not recorded as dedicated fields in audit entries. - `proxy.bind_address` and the `proxy.http.*` request-limit, timeout, Origin, and Host validation keys are part of the stable configuration surface. - The `auth.mode` and `auth.static.*` keys are part of the stable configuration surface. +- Principal-aware `policy.rules` selectors (`subject`, `issuer`, `role`, + `scope`, `method`, and `name`) are additive stable configuration keys. - The JSONL rotation keys (`audit.rotation.max_size_bytes`, `audit.rotation.max_files`, `audit.rotation.interval`, `audit.rotation.max_age_days`) are part of the stable configuration surface. ### CLI flags @@ -35,7 +37,10 @@ The following surfaces are covered by the stability policy starting at `v1.0.0`: ### Audit entry JSON schema -The fields recorded for each audit entry (`id`, `timestamp`, `direction`, `transport`, `method`, `request_id`, `tool_name`, `params`, `result`, `error`, `duration_ms`, `client_id`, `server_id`, `signature`) keep their names and types. New fields may be added in MINOR releases. Existing fields are not removed or renamed without a MAJOR bump. +The fields recorded for each audit entry (`id`, `timestamp`, `direction`, `transport`, `method`, `request_id`, `tool_name`, `params`, `result`, `error`, `duration_ms`, `client_id`, `server_id`, `principal`, `signature`) keep their names and types. New fields may be added in MINOR releases. Existing fields are not removed or renamed without a MAJOR bump. + +The `principal` object contains only authenticated `subject`, `client_id`, and +`issuer`. Raw JWT claims are never part of the audit schema. The signature is computed over `id + timestamp + method + tool_name + params`. Changing the signed field set requires a MAJOR bump because it invalidates existing signatures. diff --git a/config.yaml b/config.yaml index a547e27..33b4a3b 100644 --- a/config.yaml +++ b/config.yaml @@ -70,9 +70,13 @@ policy: default_action: allow rules: # - action: deny - # client_id: claude-desktop + # subject: alice@example.com + # issuer: https://login.example.com + # role: operator + # scope: tools:delete # server_id: filesystem - # tool_name: delete_file + # method: tools/call + # name: delete_file # reason: "Destructive filesystem operations are blocked" dashboard: diff --git a/internal/audit/logger.go b/internal/audit/logger.go index 262c1b4..66119ce 100644 --- a/internal/audit/logger.go +++ b/internal/audit/logger.go @@ -24,6 +24,13 @@ type RPCError struct { Data json.RawMessage `json:"data,omitempty"` } +// Principal is the minimal authenticated identity retained as audit evidence. +type Principal struct { + Subject string `json:"subject"` + ClientID string `json:"client_id"` + Issuer string `json:"issuer"` +} + // Entry is a single audited JSON-RPC exchange or message. type Entry struct { ID string `json:"id"` @@ -39,6 +46,7 @@ type Entry struct { DurationMs int64 `json:"duration_ms"` ClientID string `json:"client_id"` ServerID string `json:"server_id"` + Principal *Principal `json:"principal,omitempty"` Signature string `json:"signature"` } diff --git a/internal/audit/storage/sqlite.go b/internal/audit/storage/sqlite.go index 6a5f153..0aebe88 100644 --- a/internal/audit/storage/sqlite.go +++ b/internal/audit/storage/sqlite.go @@ -56,6 +56,7 @@ func (s *SQLiteStore) init(ctx context.Context) error { duration_ms INTEGER NOT NULL, client_id TEXT NOT NULL, server_id TEXT NOT NULL, + principal TEXT, signature TEXT )`, `CREATE INDEX IF NOT EXISTS idx_audit_entries_timestamp ON audit_entries(timestamp)`, @@ -72,6 +73,10 @@ func (s *SQLiteStore) init(ctx context.Context) error { !strings.Contains(err.Error(), "duplicate column name") { return fmt.Errorf("audit: sqlite: migrate request_id: %w", err) } + if _, err := s.db.ExecContext(ctx, `ALTER TABLE audit_entries ADD COLUMN principal TEXT`); err != nil && + !strings.Contains(err.Error(), "duplicate column name") { + return fmt.Errorf("audit: sqlite: migrate principal: %w", err) + } return nil } @@ -93,8 +98,8 @@ func (s *SQLiteStore) AppendBatch(entries []audit.Entry) error { } stmt, err := tx.Prepare(`INSERT INTO audit_entries ( id, timestamp, direction, transport, method, request_id, tool_name, params, result, error, - duration_ms, client_id, server_id, signature - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + duration_ms, client_id, server_id, principal, signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { _ = tx.Rollback() return fmt.Errorf("audit: sqlite: prepare batch: %w", err) @@ -110,6 +115,14 @@ func (s *SQLiteStore) AppendBatch(entries []audit.Entry) error { if entry.Error == nil { errorJSON = nil } + principalJSON, err := json.Marshal(entry.Principal) + if err != nil { + _ = tx.Rollback() + return fmt.Errorf("audit: sqlite: marshal principal: %w", err) + } + if entry.Principal == nil { + principalJSON = nil + } _, err = stmt.Exec( entry.ID, entry.Timestamp.UTC().Format(time.RFC3339Nano), @@ -124,6 +137,7 @@ func (s *SQLiteStore) AppendBatch(entries []audit.Entry) error { entry.DurationMs, entry.ClientID, entry.ServerID, + string(principalJSON), entry.Signature, ) if err != nil { @@ -140,7 +154,7 @@ func (s *SQLiteStore) AppendBatch(entries []audit.Entry) error { // Query returns recent entries matching filter. func (s *SQLiteStore) Query(filter audit.QueryFilter) ([]audit.Entry, error) { rows, err := s.db.Query(`SELECT id, timestamp, direction, transport, method, request_id, tool_name, params, result, error, - duration_ms, client_id, server_id, signature + duration_ms, client_id, server_id, principal, signature FROM audit_entries ORDER BY timestamp DESC LIMIT 10000`) @@ -152,7 +166,7 @@ func (s *SQLiteStore) Query(filter audit.QueryFilter) ([]audit.Entry, error) { var entries []audit.Entry for rows.Next() { var entry audit.Entry - var timestamp, params, result, rpcErr sql.NullString + var timestamp, params, result, rpcErr, principal sql.NullString if err := rows.Scan( &entry.ID, ×tamp, @@ -167,6 +181,7 @@ func (s *SQLiteStore) Query(filter audit.QueryFilter) ([]audit.Entry, error) { &entry.DurationMs, &entry.ClientID, &entry.ServerID, + &principal, &entry.Signature, ); err != nil { return nil, fmt.Errorf("audit: sqlite: scan: %w", err) @@ -189,6 +204,12 @@ func (s *SQLiteStore) Query(filter audit.QueryFilter) ([]audit.Entry, error) { entry.Error = &decoded } } + if principal.Valid && principal.String != "" && principal.String != "null" { + var decoded audit.Principal + if err := json.Unmarshal([]byte(principal.String), &decoded); err == nil { + entry.Principal = &decoded + } + } if audit.MatchFilter(entry, filter) { entries = append(entries, entry) } diff --git a/internal/audit/storage/sqlite_test.go b/internal/audit/storage/sqlite_test.go index 238c333..2e1513d 100644 --- a/internal/audit/storage/sqlite_test.go +++ b/internal/audit/storage/sqlite_test.go @@ -372,6 +372,21 @@ func TestSQLiteStorePersistsRequestID(t *testing.T) { } } +func TestSQLiteStorePersistsPrincipalProjection(t *testing.T) { + t.Parallel() + store := newSQLiteStore(t) + want := &audit.Principal{Subject: "alice", ClientID: "client-1", Issuer: "https://issuer.example.com"} + mustSQLiteAppend(t, store, audit.Entry{ID: "principal-1", ClientID: "client-1", ServerID: "s1", Principal: want}) + + entries, err := store.Query(audit.QueryFilter{}) + if err != nil { + t.Fatalf("Query: %v", err) + } + if len(entries) != 1 || entries[0].Principal == nil || *entries[0].Principal != *want { + t.Fatalf("principal = %#v", entries) + } +} + func TestNewSQLiteStoreCreatesParentDirectory(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "sub", "dir", "audit.db") diff --git a/internal/mcp/headers.go b/internal/mcp/headers.go new file mode 100644 index 0000000..8bf3ca4 --- /dev/null +++ b/internal/mcp/headers.go @@ -0,0 +1,50 @@ +package mcp + +import ( + "fmt" + "net/http" + "strings" +) + +const ( + HeaderMethod = "Mcp-Method" + HeaderName = "Mcp-Name" + HeaderProtocolVersion = "Mcp-Protocol-Version" +) + +type requestHeaders struct { + method string + name string + revision string +} + +func inspectHeaders(headers http.Header) (requestHeaders, error) { + method, err := singleHeader(headers, HeaderMethod) + if err != nil { + return requestHeaders{}, err + } + name, err := singleHeader(headers, HeaderName) + if err != nil { + return requestHeaders{}, err + } + revision, err := singleHeader(headers, HeaderProtocolVersion) + if err != nil { + return requestHeaders{}, err + } + return requestHeaders{method: method, name: name, revision: revision}, nil +} + +func singleHeader(headers http.Header, name string) (string, error) { + values := headers.Values(name) + if len(values) == 0 { + return "", nil + } + if len(values) != 1 { + return "", fmt.Errorf("mcp: header %s must occur at most once", name) + } + value := strings.TrimSpace(values[0]) + if value == "" || value != values[0] { + return "", fmt.Errorf("mcp: header %s must be non-empty without surrounding whitespace", name) + } + return value, nil +} diff --git a/internal/mcp/message.go b/internal/mcp/message.go new file mode 100644 index 0000000..61d07ea --- /dev/null +++ b/internal/mcp/message.go @@ -0,0 +1,85 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +const maxBatchMessages = 1024 + +// Message contains the JSON-RPC request fields needed for gateway inspection. +type Message struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` +} + +// DecodeMessages parses a single JSON-RPC request or a bounded request batch. +func DecodeMessages(raw []byte) ([]Message, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var payload json.RawMessage + if err := decoder.Decode(&payload); err != nil { + return nil, fmt.Errorf("mcp: decode request: %w", err) + } + if err := ensureJSONEOF(decoder); err != nil { + return nil, err + } + payload = bytes.TrimSpace(payload) + if len(payload) == 0 { + return nil, fmt.Errorf("mcp: request is empty") + } + if payload[0] != '[' { + message, err := decodeMessage(payload) + if err != nil { + return nil, err + } + return []Message{message}, nil + } + var rawMessages []json.RawMessage + if err := json.Unmarshal(payload, &rawMessages); err != nil { + return nil, fmt.Errorf("mcp: decode request batch: %w", err) + } + if len(rawMessages) == 0 { + return nil, fmt.Errorf("mcp: request batch is empty") + } + if len(rawMessages) > maxBatchMessages { + return nil, fmt.Errorf("mcp: request batch exceeds %d messages", maxBatchMessages) + } + messages := make([]Message, 0, len(rawMessages)) + for index, rawMessage := range rawMessages { + message, err := decodeMessage(rawMessage) + if err != nil { + return nil, fmt.Errorf("mcp: request batch item %d: %w", index, err) + } + messages = append(messages, message) + } + return messages, nil +} + +func decodeMessage(raw json.RawMessage) (Message, error) { + if trimmed := bytes.TrimSpace(raw); len(trimmed) == 0 || trimmed[0] != '{' { + return Message{}, fmt.Errorf("request message must be an object") + } + var message Message + if err := json.Unmarshal(raw, &message); err != nil { + return Message{}, fmt.Errorf("decode request message: %w", err) + } + if message.Method == "" { + return Message{}, fmt.Errorf("request method is required") + } + return message, nil +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var trailing json.RawMessage + if err := decoder.Decode(&trailing); err == io.EOF { + return nil + } else if err != nil { + return fmt.Errorf("mcp: decode trailing data: %w", err) + } + return fmt.Errorf("mcp: multiple JSON values are not allowed") +} diff --git a/internal/mcp/message_test.go b/internal/mcp/message_test.go new file mode 100644 index 0000000..c9bc881 --- /dev/null +++ b/internal/mcp/message_test.go @@ -0,0 +1,52 @@ +package mcp + +import ( + "fmt" + "strings" + "testing" +) + +func TestDecodeMessagesAcceptsSingleAndBatch(t *testing.T) { + single, err := DecodeMessages([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)) + if err != nil || len(single) != 1 || single[0].Method != "tools/list" { + t.Fatalf("single = %#v, err = %v", single, err) + } + batch, err := DecodeMessages([]byte(`[{"jsonrpc":"2.0","id":1,"method":"tools/list"},{"jsonrpc":"2.0","method":"logging/setLevel"}]`)) + if err != nil || len(batch) != 2 { + t.Fatalf("batch = %#v, err = %v", batch, err) + } +} + +func TestDecodeMessagesRejectsInvalidPayloads(t *testing.T) { + cases := []string{ + ``, + `null`, + `[]`, + `{"jsonrpc":"2.0","id":1}`, + `{"jsonrpc":"2.0","method":"ping"} {"method":"ping"}`, + `[1]`, + } + for _, body := range cases { + if _, err := DecodeMessages([]byte(body)); err == nil { + t.Fatalf("expected error for %q", body) + } + } +} + +func TestDecodeMessagesBoundsBatchCardinality(t *testing.T) { + items := make([]string, maxBatchMessages+1) + for index := range items { + items[index] = fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"ping"}`, index) + } + body := "[" + strings.Join(items, ",") + "]" + if _, err := DecodeMessages([]byte(body)); err == nil { + t.Fatal("expected oversized batch error") + } +} + +func TestInspectRequestRejectsBatchHeaders(t *testing.T) { + body := []byte(`[{"jsonrpc":"2.0","id":1,"method":"tools/list"},{"jsonrpc":"2.0","id":2,"method":"prompts/list"}]`) + if _, err := InspectRequest(nil, body); err == nil { + t.Fatal("expected batch inspection error") + } +} diff --git a/internal/mcp/metadata.go b/internal/mcp/metadata.go new file mode 100644 index 0000000..b5f31cc --- /dev/null +++ b/internal/mcp/metadata.go @@ -0,0 +1,133 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// RequestKind groups MCP methods into policy-relevant operation families. +type RequestKind string + +const ( + RequestKindUnknown RequestKind = "unknown" + RequestKindTools RequestKind = "tools" + RequestKindResources RequestKind = "resources" + RequestKindPrompts RequestKind = "prompts" + RequestKindCompletion RequestKind = "completion" + RequestKindLogging RequestKind = "logging" + RequestKindDiscovery RequestKind = "discovery" + RequestKindTasks RequestKind = "tasks" + RequestKindExtensions RequestKind = "extensions" +) + +// RequestMetadata is the normalized, non-mutating view used by gateway controls. +type RequestMetadata struct { + ProtocolRevision ProtocolRevision + Method string + Name string + RequestID string + Kind RequestKind +} + +// InspectRequest validates MCP headers against a single JSON-RPC request body. +func InspectRequest(headers http.Header, body []byte) (RequestMetadata, error) { + messages, err := DecodeMessages(body) + if err != nil { + return RequestMetadata{}, err + } + if len(messages) != 1 { + return RequestMetadata{}, fmt.Errorf("mcp: request metadata headers cannot describe a batch") + } + inspectedHeaders, err := inspectHeaders(headers) + if err != nil { + return RequestMetadata{}, err + } + message := messages[0] + name := nameFromMessage(message) + if inspectedHeaders.method != "" && inspectedHeaders.method != message.Method { + return RequestMetadata{}, fmt.Errorf("mcp: method header %q does not match body %q", inspectedHeaders.method, message.Method) + } + if inspectedHeaders.name != "" && inspectedHeaders.name != name { + return RequestMetadata{}, fmt.Errorf("mcp: name header %q does not match body %q", inspectedHeaders.name, name) + } + revision, err := protocolRevision(inspectedHeaders.revision, message) + if err != nil { + return RequestMetadata{}, err + } + return RequestMetadata{ + ProtocolRevision: revision, + Method: message.Method, + Name: name, + RequestID: requestID(message.ID), + Kind: classifyMethod(message.Method), + }, nil +} + +func classifyMethod(method string) RequestKind { + if method == "server/discover" { + return RequestKindDiscovery + } + family, _, _ := strings.Cut(method, "/") + switch family { + case "tools": + return RequestKindTools + case "resources": + return RequestKindResources + case "prompts": + return RequestKindPrompts + case "completion": + return RequestKindCompletion + case "logging": + return RequestKindLogging + case "tasks": + return RequestKindTasks + case "extensions": + return RequestKindExtensions + default: + return RequestKindUnknown + } +} + +func nameFromMessage(message Message) string { + if len(message.Params) == 0 { + return "" + } + var params struct { + Name string `json:"name"` + ToolName string `json:"tool_name"` + URI string `json:"uri"` + Ref struct { + Name string `json:"name"` + URI string `json:"uri"` + } `json:"ref"` + } + if json.Unmarshal(message.Params, ¶ms) != nil { + return "" + } + if params.Name != "" { + return params.Name + } + if params.ToolName != "" { + return params.ToolName + } + if params.URI != "" { + return params.URI + } + if params.Ref.Name != "" { + return params.Ref.Name + } + return params.Ref.URI +} + +func requestID(id json.RawMessage) string { + if len(id) == 0 || string(id) == "null" { + return "" + } + var value string + if json.Unmarshal(id, &value) == nil { + return value + } + return string(id) +} diff --git a/internal/mcp/metadata_test.go b/internal/mcp/metadata_test.go new file mode 100644 index 0000000..e21951d --- /dev/null +++ b/internal/mcp/metadata_test.go @@ -0,0 +1,111 @@ +package mcp + +import ( + "net/http" + "reflect" + "testing" +) + +func TestInspectRequestClassifiesOperationFamilies(t *testing.T) { + cases := []struct { + name string + body string + method string + entityName string + kind RequestKind + }{ + {name: "tool call", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_file"}}`, method: "tools/call", entityName: "delete_file", kind: RequestKindTools}, + {name: "resource read", body: `{"jsonrpc":"2.0","id":"r1","method":"resources/read","params":{"uri":"file:///tmp/a"}}`, method: "resources/read", entityName: "file:///tmp/a", kind: RequestKindResources}, + {name: "prompt get", body: `{"jsonrpc":"2.0","id":2,"method":"prompts/get","params":{"name":"review"}}`, method: "prompts/get", entityName: "review", kind: RequestKindPrompts}, + {name: "completion", body: `{"jsonrpc":"2.0","id":3,"method":"completion/complete","params":{"ref":{"name":"review"}}}`, method: "completion/complete", entityName: "review", kind: RequestKindCompletion}, + {name: "logging", body: `{"jsonrpc":"2.0","method":"logging/setLevel","params":{"level":"debug"}}`, method: "logging/setLevel", kind: RequestKindLogging}, + {name: "discover", body: `{"jsonrpc":"2.0","id":4,"method":"server/discover"}`, method: "server/discover", kind: RequestKindDiscovery}, + {name: "task", body: `{"jsonrpc":"2.0","id":5,"method":"tasks/get","params":{"name":"task-1"}}`, method: "tasks/get", entityName: "task-1", kind: RequestKindTasks}, + {name: "extension", body: `{"jsonrpc":"2.0","id":6,"method":"extensions/acme.run","params":{"name":"job"}}`, method: "extensions/acme.run", entityName: "job", kind: RequestKindExtensions}, + {name: "unknown", body: `{"jsonrpc":"2.0","id":7,"method":"ping"}`, method: "ping", kind: RequestKindUnknown}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + metadata, err := InspectRequest(nil, []byte(tc.body)) + if err != nil { + t.Fatalf("inspect request: %v", err) + } + if metadata.Method != tc.method || metadata.Name != tc.entityName || metadata.Kind != tc.kind { + t.Fatalf("metadata = %#v", metadata) + } + }) + } +} + +func TestInspectRequestValidatesHeadersAgainstBody(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"delete_file","_meta":{"protocolRevision":"2026-07-28"}}}`) + headers := http.Header{ + HeaderMethod: {"tools/call"}, + HeaderName: {"delete_file"}, + HeaderProtocolVersion: {"2026-07-28"}, + } + metadata, err := InspectRequest(headers, body) + if err != nil { + t.Fatalf("inspect request: %v", err) + } + want := RequestMetadata{ + ProtocolRevision: Protocol20260728, + Method: "tools/call", + Name: "delete_file", + RequestID: "42", + Kind: RequestKindTools, + } + if !reflect.DeepEqual(metadata, want) { + t.Fatalf("metadata = %#v, want %#v", metadata, want) + } +} + +func TestInspectRequestRejectsMetadataMismatch(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_file"}}`) + cases := []http.Header{ + {HeaderMethod: {"resources/read"}}, + {HeaderName: {"read_file"}}, + {HeaderProtocolVersion: {"2026-07-28", "2025-11-25"}}, + {HeaderProtocolVersion: {"2099-01-01"}}, + } + for _, headers := range cases { + if _, err := InspectRequest(headers, body); err == nil { + t.Fatalf("expected mismatch for headers %#v", headers) + } + } +} + +func TestInspectRequestDetectsRevision(t *testing.T) { + cases := []struct { + name string + body string + want ProtocolRevision + }{ + {name: "legacy fallback", body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, want: ProtocolLegacy20251125}, + {name: "legacy initialize", body: `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}`, want: ProtocolLegacy20251125}, + {name: "self contained meta", body: `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"protocolVersion":"2026-07-28"}}}`, want: Protocol20260728}, + {name: "discover", body: `{"jsonrpc":"2.0","id":1,"method":"server/discover"}`, want: Protocol20260728}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + metadata, err := InspectRequest(nil, []byte(tc.body)) + if err != nil { + t.Fatalf("inspect request: %v", err) + } + if metadata.ProtocolRevision != tc.want { + t.Fatalf("revision = %q, want %q", metadata.ProtocolRevision, tc.want) + } + }) + } +} + +func TestInspectRequestDoesNotMutateBody(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"requestState":{"round":2},"cache":{"ttl":60}}}`) + want := append([]byte(nil), body...) + if _, err := InspectRequest(nil, body); err != nil { + t.Fatalf("inspect request: %v", err) + } + if !reflect.DeepEqual(body, want) { + t.Fatalf("body mutated: %s", body) + } +} diff --git a/internal/mcp/revision.go b/internal/mcp/revision.go new file mode 100644 index 0000000..6fa2961 --- /dev/null +++ b/internal/mcp/revision.go @@ -0,0 +1,66 @@ +package mcp + +import ( + "encoding/json" + "fmt" +) + +// ProtocolRevision identifies an MCP protocol revision understood by the proxy. +type ProtocolRevision string + +const ( + ProtocolLegacy20251125 ProtocolRevision = "2025-11-25" + Protocol20260728 ProtocolRevision = "2026-07-28" +) + +func protocolRevision(headerRevision string, message Message) (ProtocolRevision, error) { + bodyRevision := revisionFromMessage(message) + if headerRevision != "" { + revision := ProtocolRevision(headerRevision) + if !revision.Supported() { + return "", fmt.Errorf("mcp: unsupported protocol revision %q", headerRevision) + } + if bodyRevision != "" && bodyRevision != revision { + return "", fmt.Errorf("mcp: protocol revision header %q does not match body %q", revision, bodyRevision) + } + return revision, nil + } + if bodyRevision != "" { + if !bodyRevision.Supported() { + return "", fmt.Errorf("mcp: unsupported protocol revision %q", bodyRevision) + } + return bodyRevision, nil + } + if message.Method == "server/discover" { + return Protocol20260728, nil + } + return ProtocolLegacy20251125, nil +} + +// Supported reports whether the revision has explicit gateway inspection rules. +func (r ProtocolRevision) Supported() bool { + return r == ProtocolLegacy20251125 || r == Protocol20260728 +} + +func revisionFromMessage(message Message) ProtocolRevision { + if len(message.Params) == 0 { + return "" + } + var params struct { + ProtocolVersion string `json:"protocolVersion"` + Meta struct { + ProtocolVersion string `json:"protocolVersion"` + ProtocolRevision string `json:"protocolRevision"` + } `json:"_meta"` + } + if json.Unmarshal(message.Params, ¶ms) != nil { + return "" + } + if params.ProtocolVersion != "" { + return ProtocolRevision(params.ProtocolVersion) + } + if params.Meta.ProtocolRevision != "" { + return ProtocolRevision(params.Meta.ProtocolRevision) + } + return ProtocolRevision(params.Meta.ProtocolVersion) +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 069aa56..0c30dba 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -24,16 +24,28 @@ type Config struct { // Rule matches tool call context and returns an allow or deny decision. type Rule struct { Action string `mapstructure:"action"` + Subject string `mapstructure:"subject"` ClientID string `mapstructure:"client_id"` + Issuer string `mapstructure:"issuer"` + Role string `mapstructure:"role"` + Scope string `mapstructure:"scope"` ServerID string `mapstructure:"server_id"` + Method string `mapstructure:"method"` + Name string `mapstructure:"name"` ToolName string `mapstructure:"tool_name"` Reason string `mapstructure:"reason"` } // Request is the context used to evaluate a tool call. type Request struct { + Subject string ClientID string + Issuer string + Roles []string + Scopes []string ServerID string + Method string + Name string ToolName string } @@ -81,8 +93,14 @@ func (e *Engine) Evaluate(request Request) Decision { return Decision{Allowed: true, Action: ActionAllow, RuleIndex: -1} } for i, rule := range e.rules { - if !matches(rule.ClientID, request.ClientID) || + if !matches(rule.Subject, request.Subject) || + !matches(rule.ClientID, request.ClientID) || + !matches(rule.Issuer, request.Issuer) || + !matchesAny(rule.Role, request.Roles) || + !matchesAny(rule.Scope, request.Scopes) || !matches(rule.ServerID, request.ServerID) || + !matches(rule.Method, request.Method) || + !matches(rule.Name, request.Name) || !matches(rule.ToolName, request.ToolName) { continue } @@ -109,6 +127,19 @@ func (e *Engine) Evaluate(request Request) Decision { } } +func matchesAny(pattern string, values []string) bool { + pattern = strings.TrimSpace(pattern) + if pattern == "" || pattern == "*" { + return true + } + for _, value := range values { + if pattern == value { + return true + } + } + return false +} + func normalizeAction(action string) string { return strings.ToLower(strings.TrimSpace(action)) } diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index c02143f..0adab2d 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -156,3 +156,47 @@ func TestEvaluateWildcardMatchesAnyValue(t *testing.T) { t.Fatal("wildcard rule should deny any client/tool") } } + +func TestEngineMatchesAuthenticatedPrincipalAndOperation(t *testing.T) { + engine, err := NewEngine(Config{ + Enabled: true, + DefaultAction: ActionAllow, + Rules: []Rule{{ + Action: ActionDeny, + Subject: "alice", + ClientID: "client-1", + Issuer: "https://issuer.example.com", + Role: "operator", + Scope: "tools:delete", + ServerID: "filesystem", + Method: "tools/call", + Name: "delete_file", + }}, + }) + if err != nil { + t.Fatalf("new engine: %v", err) + } + request := Request{ + Subject: "alice", + ClientID: "client-1", + Issuer: "https://issuer.example.com", + Roles: []string{"reader", "operator"}, + Scopes: []string{"tools:read", "tools:delete"}, + ServerID: "filesystem", + Method: "tools/call", + Name: "delete_file", + ToolName: "delete_file", + } + if engine.Evaluate(request).Allowed { + t.Fatal("matching authenticated principal was allowed") + } + request.Subject = "bob" + if !engine.Evaluate(request).Allowed { + t.Fatal("non-matching subject was denied") + } + request.Subject = "alice" + request.Scopes = []string{"tools:read"} + if !engine.Evaluate(request).Allowed { + t.Fatal("principal without required scope was denied by non-matching rule") + } +} diff --git a/internal/proxy/http.go b/internal/proxy/http.go index 83f6c82..661ed7f 100644 --- a/internal/proxy/http.go +++ b/internal/proxy/http.go @@ -251,7 +251,7 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { } _ = r.Body.Close() - pending, reject := p.observeHTTPRequest(body, startedAt) + pending, reject := p.observeHTTPRequest(body, startedAt, principal) if reject != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -398,7 +398,7 @@ func (p *HTTPProxy) targetURL(r *http.Request) *url.URL { return &target } -func (p *HTTPProxy) observeHTTPRequest(raw []byte, startedAt time.Time) (map[string]pendingCall, []byte) { +func (p *HTTPProxy) observeHTTPRequest(raw []byte, startedAt time.Time, principal *auth.Principal) (map[string]pendingCall, []byte) { pending := make(map[string]pendingCall) if len(bytes.TrimSpace(raw)) == 0 { return pending, nil @@ -419,9 +419,10 @@ func (p *HTTPProxy) observeHTTPRequest(raw []byte, startedAt time.Time) (map[str toolName: toolName, params: msg.Params, startedAt: startedAt, + principal: auditPrincipal(principal), } if msg.Method == "tools/call" { - decision := p.evaluatePolicy(toolName) + decision := p.evaluatePolicy(principal, msg.Method, toolName) p.recordPolicyDecision(decision) if !decision.Allowed { rpcErr := policyError(decision) @@ -431,9 +432,9 @@ func (p *HTTPProxy) observeHTTPRequest(raw []byte, startedAt time.Time) (map[str return pending, buildErrorResponse(msg.ID, rpcErr) } } - if msg.Method == "tools/call" && !p.config.Limiter.Allow(p.config.ClientID, toolName) { + if msg.Method == "tools/call" && !p.config.Limiter.Allow(principal.ClientID, toolName) { if p.config.Metrics != nil { - p.config.Metrics.RecordRateLimitRejection(p.config.ClientID, toolName) + p.config.Metrics.RecordRateLimitRejection(principal.ClientID, toolName) } rpcErr := &audit.RPCError{Code: -32029, Message: "rate limit exceeded"} if err := p.record(call, audit.DirectionClientToServer, nil, rpcErr); err != nil { @@ -509,6 +510,10 @@ func (p *HTTPProxy) streamSSE(w http.ResponseWriter, body io.Reader, pending map } func (p *HTTPProxy) record(call pendingCall, direction string, result json.RawMessage, rpcErr *audit.RPCError) error { + principal := call.principal + if principal == nil { + principal = staticAuditPrincipal(p.config.ClientID) + } return p.config.Audit.Record(audit.Entry{ Direction: direction, Method: call.method, @@ -518,19 +523,26 @@ func (p *HTTPProxy) record(call pendingCall, direction string, result json.RawMe Result: result, Error: rpcErr, DurationMs: time.Since(call.startedAt).Milliseconds(), - ClientID: p.config.ClientID, + ClientID: principal.ClientID, ServerID: p.config.ServerID, + Principal: principal, }) } -func (p *HTTPProxy) evaluatePolicy(toolName string) policy.Decision { +func (p *HTTPProxy) evaluatePolicy(principal *auth.Principal, method, name string) policy.Decision { if p.config.Policy == nil { return policy.Decision{Allowed: true, Action: policy.ActionAllow, RuleIndex: -1} } return p.config.Policy.Evaluate(policy.Request{ - ClientID: p.config.ClientID, + Subject: principal.Subject, + ClientID: principal.ClientID, + Issuer: principal.Issuer, + Roles: principal.Roles, + Scopes: principal.Scopes, ServerID: p.config.ServerID, - ToolName: toolName, + Method: method, + Name: name, + ToolName: name, }) } diff --git a/internal/proxy/http_test.go b/internal/proxy/http_test.go index 4d2ec37..775ee85 100644 --- a/internal/proxy/http_test.go +++ b/internal/proxy/http_test.go @@ -15,6 +15,7 @@ import ( "github.com/P4ST4S/mcp-audit/internal/auth" "github.com/P4ST4S/mcp-audit/internal/httpclient" "github.com/P4ST4S/mcp-audit/internal/middleware" + "github.com/P4ST4S/mcp-audit/internal/policy" ) func TestHTTPProxyStripsAuthorizationByDefault(t *testing.T) { @@ -174,6 +175,69 @@ func TestHTTPProxyAuthenticatesStaticBearerPrincipal(t *testing.T) { } } +func TestHTTPProxyUsesAuthenticatedPrincipalForPolicyAndAudit(t *testing.T) { + authenticator, err := auth.NewNoneAuthenticator(auth.Principal{ + Subject: "alice", + ClientID: "client-1", + Issuer: "https://issuer.example.com", + Roles: []string{"operator"}, + Scopes: []string{"tools:delete"}, + }) + if err != nil { + t.Fatalf("new authenticator: %v", err) + } + engine, err := policy.NewEngine(policy.Config{ + Enabled: true, + DefaultAction: policy.ActionAllow, + Rules: []policy.Rule{{ + Action: policy.ActionDeny, + Subject: "alice", + Issuer: "https://issuer.example.com", + Role: "operator", + Scope: "tools:delete", + Method: "tools/call", + Name: "delete_file", + }}, + }) + if err != nil { + t.Fatalf("new policy engine: %v", err) + } + store := &memoryAuditStore{} + proxy, err := NewHTTPProxy(HTTPConfig{ + Upstream: "http://upstream.local", + Authenticator: authenticator, + Policy: engine, + Limiter: middleware.NewRateLimiter(false, 0), + Audit: audit.NewLogger(audit.LoggerConfig{Store: store, Transport: "http"}), + ServerID: "filesystem", + }) + if err != nil { + t.Fatalf("new http proxy: %v", err) + } + upstreamCalls := 0 + proxy.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + upstreamCalls++ + return okJSONResponse(), nil + }) + req := httptest.NewRequest(http.MethodPost, "http://proxy.local/rpc", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_file"}}`)) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if upstreamCalls != 0 { + t.Fatalf("upstream calls = %d, want 0", upstreamCalls) + } + if len(store.entries) != 1 { + t.Fatalf("audit entries = %d, want 1", len(store.entries)) + } + entry := store.entries[0] + if entry.ClientID != "client-1" || entry.Principal == nil || entry.Principal.Subject != "alice" || entry.Principal.Issuer != "https://issuer.example.com" { + t.Fatalf("audit identity = %#v", entry) + } + if entry.Error == nil || entry.Error.Code != policyDeniedCode { + t.Fatalf("audit error = %#v", entry.Error) + } +} + func TestHTTPProxyRejectsOversizedRequestBody(t *testing.T) { metrics := &httpRejectionMetrics{} upstreamCalls := 0 diff --git a/internal/proxy/stdio.go b/internal/proxy/stdio.go index 5d1c48d..8be7f76 100644 --- a/internal/proxy/stdio.go +++ b/internal/proxy/stdio.go @@ -13,6 +13,7 @@ import ( "time" "github.com/P4ST4S/mcp-audit/internal/audit" + "github.com/P4ST4S/mcp-audit/internal/auth" "github.com/P4ST4S/mcp-audit/internal/middleware" "github.com/P4ST4S/mcp-audit/internal/policy" ) @@ -312,6 +313,10 @@ func (p *StdioProxy) observeServerMessage(raw []byte) { } func (p *StdioProxy) record(call pendingCall, direction string, result json.RawMessage, rpcErr *audit.RPCError) error { + principal := call.principal + if principal == nil { + principal = staticAuditPrincipal(p.config.ClientID) + } return p.config.Audit.Record(audit.Entry{ Direction: direction, Method: call.method, @@ -323,6 +328,7 @@ func (p *StdioProxy) record(call pendingCall, direction string, result json.RawM DurationMs: time.Since(call.startedAt).Milliseconds(), ClientID: p.config.ClientID, ServerID: p.config.ServerID, + Principal: principal, }) } @@ -331,8 +337,12 @@ func (p *StdioProxy) evaluatePolicy(toolName string) policy.Decision { return policy.Decision{Allowed: true, Action: policy.ActionAllow, RuleIndex: -1} } return p.config.Policy.Evaluate(policy.Request{ + Subject: p.config.ClientID, ClientID: p.config.ClientID, + Issuer: "static", ServerID: p.config.ServerID, + Method: "tools/call", + Name: toolName, ToolName: toolName, }) } @@ -350,6 +360,18 @@ type pendingCall struct { toolName string params json.RawMessage startedAt time.Time + principal *audit.Principal +} + +func auditPrincipal(principal *auth.Principal) *audit.Principal { + if principal == nil { + return nil + } + return &audit.Principal{Subject: principal.Subject, ClientID: principal.ClientID, Issuer: principal.Issuer} +} + +func staticAuditPrincipal(clientID string) *audit.Principal { + return &audit.Principal{Subject: clientID, ClientID: clientID, Issuer: "static"} } type rpcState struct {