mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
d8b1d98e08
* 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.
106 lines
3.8 KiB
Go
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
|
|
}
|