Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions audit/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package audit

import (
"io"
"log/slog"
"os"
)

// NewAuditLogger creates a new structured audit logger that writes to the
// specified writer. A nil writer defaults to os.Stdout.
//
// The logger emits JSON at LevelAudit with the level rendered as the string
// "AUDIT" for compatibility with log aggregation systems (Loki,
// Elasticsearch, etc.) that expect standard level names — without the
// rewrite, audit events would appear as "INFO+2" and break level-based
// filtering.
func NewAuditLogger(w io.Writer) *slog.Logger {
if w == nil {
w = os.Stdout
}

handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: LevelAudit,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.LevelKey {
if level, ok := a.Value.Any().(slog.Level); ok && level == LevelAudit {
a.Value = slog.StringValue("AUDIT")
}
}
return a
},
})

return slog.New(handler)
}
90 changes: 90 additions & 0 deletions audit/logger_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package audit

import (
"bytes"
"encoding/json"
"log/slog"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewAuditLogger_EmitsAuditLevelString(t *testing.T) {
t.Parallel()

var buf bytes.Buffer
logger := NewAuditLogger(&buf)

logger.Log(t.Context(), LevelAudit, "test audit event", slog.String("key", "value"))

var record map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &record))
assert.Equal(t, "AUDIT", record["level"], "level must render as AUDIT, not INFO+2")
assert.Equal(t, "test audit event", record["msg"])
assert.Equal(t, "value", record["key"])
}

func TestNewAuditLogger_SuppressesLowerLevels(t *testing.T) {
t.Parallel()

var buf bytes.Buffer
logger := NewAuditLogger(&buf)

logger.Info("should not appear")
assert.Empty(t, buf.String(), "INFO (0) is below LevelAudit (2) and must be suppressed")
}

func TestNewAuditLogger_NilWriterDefaultsToStdout(t *testing.T) {
t.Parallel()

// Must not panic; we can't easily capture os.Stdout here, just verify construction.
logger := NewAuditLogger(nil)
require.NotNil(t, logger)
assert.True(t, logger.Enabled(t.Context(), LevelAudit))
}

func TestEventTypeConstants(t *testing.T) {
t.Parallel()

// Pin the wire values: these strings land in log aggregation queries and
// dashboards, so renaming one is a breaking change.
want := map[string]string{
"EventTypeMCPInitialize": EventTypeMCPInitialize,
"EventTypeMCPToolCall": EventTypeMCPToolCall,
"EventTypeMCPToolsList": EventTypeMCPToolsList,
"EventTypeMCPResourceRead": EventTypeMCPResourceRead,
"EventTypeMCPResourcesList": EventTypeMCPResourcesList,
"EventTypeMCPPromptGet": EventTypeMCPPromptGet,
"EventTypeMCPPromptsList": EventTypeMCPPromptsList,
"EventTypeMCPNotification": EventTypeMCPNotification,
"EventTypeMCPPing": EventTypeMCPPing,
"EventTypeMCPLogging": EventTypeMCPLogging,
"EventTypeMCPCompletion": EventTypeMCPCompletion,
"EventTypeMCPRootsListChanged": EventTypeMCPRootsListChanged,
"EventTypeSSEConnection": EventTypeSSEConnection,
"EventTypeWorkflowStarted": EventTypeWorkflowStarted,
"EventTypeWorkflowCompleted": EventTypeWorkflowCompleted,
"EventTypeWorkflowFailed": EventTypeWorkflowFailed,
"EventTypeWorkflowTimedOut": EventTypeWorkflowTimedOut,
"EventTypeWorkflowStepStarted": EventTypeWorkflowStepStarted,
"EventTypeWorkflowStepCompleted": EventTypeWorkflowStepCompleted,
"EventTypeWorkflowStepFailed": EventTypeWorkflowStepFailed,
"EventTypeWorkflowStepSkipped": EventTypeWorkflowStepSkipped,
"EventTypeMCPRequest": EventTypeMCPRequest,
"EventTypeHTTPRequest": EventTypeHTTPRequest,
}
for name, got := range want {
assert.NotEmpty(t, got, "%s must not be empty", name)
}

// Spot-check the exact wire format for the most-queried events.
assert.Equal(t, "mcp_tool_call", EventTypeMCPToolCall)
assert.Equal(t, "mcp_initialize", EventTypeMCPInitialize)
assert.Equal(t, "vmcp_workflow_started", EventTypeWorkflowStarted)
assert.Equal(t, "vmcp_workflow_step_failed", EventTypeWorkflowStepFailed)
assert.Equal(t, "http_request", EventTypeHTTPRequest)
}
139 changes: 139 additions & 0 deletions audit/mcp_events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package audit provides MCP-specific audit event types and constants.
package audit

// MCP-specific event types based on the Model Context Protocol specification
const (
// EventTypeMCPInitialize represents an MCP initialization event
EventTypeMCPInitialize = "mcp_initialize"
// EventTypeSSEConnection represents an SSE connection event
EventTypeSSEConnection = "sse_connection"
// EventTypeMCPToolCall represents an MCP tool call event
EventTypeMCPToolCall = "mcp_tool_call"
// EventTypeMCPToolsList represents an MCP tools list event
EventTypeMCPToolsList = "mcp_tools_list"
// EventTypeMCPResourceRead represents an MCP resource read event
EventTypeMCPResourceRead = "mcp_resource_read"
// EventTypeMCPResourcesList represents an MCP resources list event
EventTypeMCPResourcesList = "mcp_resources_list"
// EventTypeMCPPromptGet represents an MCP prompt get event
EventTypeMCPPromptGet = "mcp_prompt_get"
// EventTypeMCPPromptsList represents an MCP prompts list event
EventTypeMCPPromptsList = "mcp_prompts_list"
// EventTypeMCPNotification represents an MCP notification event
EventTypeMCPNotification = "mcp_notification"
// EventTypeMCPPing represents an MCP ping event
EventTypeMCPPing = "mcp_ping"
// EventTypeMCPLogging represents an MCP logging event
EventTypeMCPLogging = "mcp_logging"
// EventTypeMCPCompletion represents an MCP completion event
EventTypeMCPCompletion = "mcp_completion"
// EventTypeMCPRootsListChanged represents an MCP roots list changed notification
EventTypeMCPRootsListChanged = "mcp_roots_list_changed"

// Workflow-specific event types for vMCP composite workflow execution
// EventTypeWorkflowStarted represents workflow execution start
EventTypeWorkflowStarted = "vmcp_workflow_started"
// EventTypeWorkflowCompleted represents successful workflow completion
EventTypeWorkflowCompleted = "vmcp_workflow_completed"
// EventTypeWorkflowFailed represents workflow failure
EventTypeWorkflowFailed = "vmcp_workflow_failed"
// EventTypeWorkflowTimedOut represents workflow timeout
EventTypeWorkflowTimedOut = "vmcp_workflow_timed_out"
// EventTypeWorkflowStepStarted represents workflow step execution start
EventTypeWorkflowStepStarted = "vmcp_workflow_step_started"
// EventTypeWorkflowStepCompleted represents successful step completion
EventTypeWorkflowStepCompleted = "vmcp_workflow_step_completed"
// EventTypeWorkflowStepFailed represents step failure
EventTypeWorkflowStepFailed = "vmcp_workflow_step_failed"
// EventTypeWorkflowStepSkipped represents conditional step skip
EventTypeWorkflowStepSkipped = "vmcp_workflow_step_skipped"

// Fallback event types for unrecognized or generic requests
// EventTypeMCPRequest represents a generic MCP request when specific type cannot be determined
EventTypeMCPRequest = "mcp_request"
// EventTypeHTTPRequest represents a generic HTTP request (non-MCP)
EventTypeHTTPRequest = "http_request"
)

// MCP target types for audit events
const (
// TargetTypeTool represents a tool target
TargetTypeTool = "tool"
// TargetTypeResource represents a resource target
TargetTypeResource = "resource"
// TargetTypePrompt represents a prompt target
TargetTypePrompt = "prompt"
// TargetTypeServer represents a server target
TargetTypeServer = "server"
// TargetTypeWorkflow represents a workflow target
TargetTypeWorkflow = "workflow"
// TargetTypeWorkflowStep represents a workflow step target
TargetTypeWorkflowStep = "workflow_step"
)

// MCP-specific target field keys
const (
// TargetKeyType is the key for the target type in the target map
TargetKeyType = "type"
// TargetKeyName is the key for the target name in the target map
TargetKeyName = "name"
// TargetKeyURI is the key for the target URI in the target map
TargetKeyURI = "uri"
// TargetKeyMethod is the key for the MCP method in the target map
TargetKeyMethod = "method"
// TargetKeyEndpoint is the key for the endpoint in the target map
TargetKeyEndpoint = "endpoint"
// TargetKeyWorkflowID is the key for the unique workflow execution ID
TargetKeyWorkflowID = "workflow_id"
// TargetKeyWorkflowName is the key for the workflow definition name
TargetKeyWorkflowName = "workflow_name"
// TargetKeyStepID is the key for the step identifier
TargetKeyStepID = "step_id"
// TargetKeyStepType is the key for the step type (tool, elicitation)
TargetKeyStepType = "step_type"
// TargetKeyToolName is the key for the tool being called (for tool steps)
TargetKeyToolName = "tool_name"
)

// MCP-specific subject field keys
const (
// SubjectKeyUser is the key for the user in the subjects map
SubjectKeyUser = "user"
// SubjectKeyUserID is the key for the user ID in the subjects map
SubjectKeyUserID = "user_id"
// SubjectKeyClientName is the key for the client name in the subjects map
SubjectKeyClientName = "client_name"
// SubjectKeyClientVersion is the key for the client version in the subjects map
SubjectKeyClientVersion = "client_version"
)

// MCP-specific source field keys for EventSource.Extra
const (
// SourceExtraKeyUserAgent is the key for the user agent in the source extra map
SourceExtraKeyUserAgent = "user_agent"
// SourceExtraKeyRequestID is the key for the request ID in the source extra map
SourceExtraKeyRequestID = "request_id"
// SourceExtraKeySessionID is the key for the session ID in the source extra map
SourceExtraKeySessionID = "session_id"
)

// MCP-specific metadata field keys for EventMetadata.Extra
const (
// MetadataExtraKeyMCPVersion is the key for the MCP version in the metadata extra map
MetadataExtraKeyMCPVersion = "mcp_version"
// MetadataExtraKeyTransport is the key for the transport type in the metadata extra map
MetadataExtraKeyTransport = "transport"
// MetadataExtraKeyDuration is the key for the request duration in the metadata extra map
MetadataExtraKeyDuration = "duration_ms"
// MetadataExtraKeyResponseSize is the key for the response size in the metadata extra map
MetadataExtraKeyResponseSize = "response_size_bytes"
// MetadataExtraKeyRetryCount is the key for the number of retries performed
MetadataExtraKeyRetryCount = "retry_count"
// MetadataExtraKeyStepCount is the key for the total number of steps in a workflow
MetadataExtraKeyStepCount = "step_count"
// MetadataExtraKeyTimeout is the key for the workflow timeout in milliseconds
MetadataExtraKeyTimeout = "timeout_ms"
)