Files
pad/internal/models/mcp_audit.go
T
xarmian d8b1d98e08 feat(mcp): persistent audit log for /mcp tool calls (TASK-960) (#389)
* feat(mcp): persistent audit log for /mcp tool calls (TASK-960)

Adds a 90-day-retention audit log of every MCP request. Drives the
"last used" + "30-day calls" columns the connected-apps page (TASK-954)
will read, and gives ops + on-call a forensics surface via a new
admin /console/admin/mcp-audit page.

Schema deviation from the spec, documented in migration 049:
the original task body called for `token_id REFERENCES oauth_tokens(id)`
but pad has no `oauth_tokens` table — instead an OAuth grant chain is
identified by `request_id` (preserved across refresh-token rotations,
see migration 048), and PAT-authenticated MCP requests have no OAuth
identity at all. The audit row therefore carries `(token_kind,
token_ref)` — `oauth` + request_id for OAuth, or `pat` + api_tokens.id
for PATs. The connected-apps page in TASK-954 will filter on
token_kind='oauth' to surface third-party connections only.

Pieces:
- internal/store/migrations/049_mcp_audit.sql + pgmigrations/028 — table.
- internal/models/mcp_audit.go — typed entry + 30-day stats DTO.
- internal/store/mcp_audit.go — insert / list-by-user / list-by-connection
  / list-all / per-connection-stats aggregator / 90-day retention sweeper.
- internal/server/middleware_mcp_audit.go — async writer + sweeper +
  middleware that wraps /mcp behind MCPBearerAuth. Hot path is
  non-blocking enqueue with drop-on-overflow + atomic drop counter.
- internal/server/middleware_mcp_auth.go — both PAT + OAuth branches now
  stash WithMCPTokenIdentity so the audit row attributes correctly.
- internal/server/handlers_mcp_audit.go — read endpoints:
  GET /api/v1/connected-apps/{id}/audit (owner-scoped) +
  GET /api/v1/admin/mcp-audit (admin-only).
- web/src/routes/console/admin/mcp-audit/+page.svelte + tab in admin layout.
- Tests cover required-field validation, round-trip, pagination,
  owner-only filtering, last-used + 30-day aggregates, retention sweep,
  body-sniff parser, canonical-JSON arg hashing, buffer-full drop path,
  status-to-result classification, admin gate, DTO field shape.

`make check` clean (lint + go test ./... + svelte-kit build).

Parent: PLAN-943.

* fix(mcp-audit): emit denied row on rate-limit reject per Codex review (round 1)

PR #389 round 1 caught: MCPAuditLog is mounted INSIDE MCPBearerAuth, so
when bearer auth's per-token rate-limit fires (429) it returns before
next.ServeHTTP — and the wrapping audit middleware never sees the
response. classifyMCPResult mapped 401/403/429 with no path that could
actually reach it.

Fix: emitMCPAuditDenied helper called directly from the rate-limit
deny branches of both PAT + OAuth paths. Resolved user + token
identity are already in scope at that point, so the audit row gets
attributed correctly. Pre-auth rejections (no/invalid bearer) stay
un-audited because there's no user to attribute them to and the
audit_trail table covers those auth-event signals already.

Threading: handleMCPPATAuth + handleMCPOAuthAuth now take the entry
timestamp so the denied row carries real latency.

Test: TestMCPAudit_RateLimited_RecordsDeniedRow drives a real PAT
through the rate limiter, drains to 429, and asserts the audit row
lands with status="denied" + error_kind="rate_limited" + the right
tool_name from the request body.
2026-05-02 22:56:10 -04:00

106 lines
3.8 KiB
Go

package models
import "time"
// MCP audit log models (PLAN-943 TASK-960).
//
// Persistent record of every request that hits the /mcp endpoint —
// successful tool calls, denied calls, errors. Drives the connected-
// apps "last used" + "30-day calls" surfaces and gives forensics a
// per-user / per-connection trail.
// TokenKind discriminates the bearer that authenticated an MCP
// request. TASK-960's spec said `token_id REFERENCES oauth_tokens(id)`;
// pad has no oauth_tokens table — instead two separate token systems
// can authenticate /mcp:
//
// - "oauth": fosite-issued access token. token_ref carries the
// OAuth request_id (chain identifier preserved across rotations,
// see internal/store/oauth.go's request_id column). One value per
// "connection" the user authorized via consent.
// - "pat": personal access token from the api_tokens table.
// token_ref is api_tokens.id. PATs predate the OAuth server but
// are still a supported MCP transport for CLI / dev use.
//
// We accept both so the connected-apps page can filter to OAuth only
// (since "PATs" aren't third-party connections you'd revoke from a
// management UI), while the per-user audit query still covers every
// MCP call regardless of how it was authenticated.
type TokenKind string
const (
TokenKindOAuth TokenKind = "oauth"
TokenKindPAT TokenKind = "pat"
)
// MCPAuditResultStatus is the outcome enum for an MCP request.
type MCPAuditResultStatus string
const (
MCPAuditResultOK MCPAuditResultStatus = "ok"
MCPAuditResultError MCPAuditResultStatus = "error"
MCPAuditResultDenied MCPAuditResultStatus = "denied"
)
// MCPAuditEntry is one row of mcp_audit_log. Fields mirror the table
// 1:1 — see internal/store/migrations/049_mcp_audit.sql for the
// column-level documentation.
//
// Pointers (WorkspaceID, ErrorKind) are nullable in the DB. Empty-
// string semantics:
//
// - ArgsHash == "": the request had no `params.arguments` (e.g.
// `tools/list`, `initialize`). Audit-grouping queries that count
// "distinct arg shapes" should treat empty as a sentinel rather
// than as "all empty calls share one group".
// - ToolName: for JSON-RPC methods that aren't tool calls
// (`initialize`, `tools/list`, `resources/read`, etc.) we store
// the JSON-RPC method itself ("initialize"), prefixed with no
// namespace. For `tools/call`, we store `params.name`
// (e.g. "pad_item"). The two namespaces are disjoint by design
// — pad's tool catalog uses `pad_*` names, JSON-RPC methods use
// `<group>/<verb>` — so a single column is safe.
type MCPAuditEntry struct {
ID string
Timestamp time.Time
UserID string
WorkspaceID *string
TokenKind TokenKind
TokenRef string
ToolName string
ArgsHash string
ResultStatus MCPAuditResultStatus
ErrorKind *string
LatencyMs int
RequestID string
}
// MCPAuditEntryInput is the write-side shape for InsertMCPAuditEntry.
// Distinct from MCPAuditEntry so the store can mint the ID + accept a
// caller's already-set timestamp (the middleware records the pre-
// handler instant for accurate latency, then writes async).
type MCPAuditEntryInput struct {
Timestamp time.Time
UserID string
WorkspaceID string // empty → NULL
TokenKind TokenKind
TokenRef string
ToolName string
ArgsHash string
ResultStatus MCPAuditResultStatus
ErrorKind string // empty → NULL
LatencyMs int
RequestID string
}
// MCPConnectionStats summarizes audit-log activity for one OAuth
// connection (request_id chain). Returned by the bulk-aggregate
// queries the connected-apps page reads — fetching last-used and
// 30-day-count per connection in two queries instead of N.
type MCPConnectionStats struct {
TokenKind TokenKind
TokenRef string
LastUsedAt *time.Time // nil if no audit entries
Calls30d int
}