From 86c5a61eaa5928abff0c2fa065c3667e8fe76b59 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 7 Mar 2026 00:18:58 +0000 Subject: [PATCH] feat: add userName tracking to audit events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add userName column to AuditEvent schema with index and migration - Add GET /api/v1/auth/me endpoint returning current user identity - AuditCollector auto-fills userName from session→user map, resolved lazily via /auth/me on first session creation - Support userName and date range (from/to) filtering on audit events and sessions endpoints - Audit console sidebar groups sessions by project → user - Add date filter presets (d key: all/today/1h/24h/7d) to console - Add scrolling and page up/down to sidebar navigation - Tests: auth-me (4), audit-username collector (4), route filters (2), smoke tests (2) Co-Authored-By: Claude Opus 4.6 --- src/cli/src/commands/console/audit-app.tsx | 252 ++++++++++++++---- src/cli/src/commands/console/audit-types.ts | 29 ++ .../migration.sql | 5 + src/db/prisma/schema.prisma | 2 + .../repositories/audit-event.repository.ts | 49 +++- src/mcpd/src/repositories/interfaces.ts | 7 +- src/mcpd/src/routes/audit-events.ts | 9 +- src/mcpd/src/routes/auth.ts | 6 + src/mcpd/src/services/audit-event.service.ts | 14 +- src/mcpd/tests/audit-event-routes.test.ts | 31 ++- src/mcpd/tests/auth-me.test.ts | 152 +++++++++++ src/mcplocal/src/audit/collector.ts | 15 +- src/mcplocal/src/audit/types.ts | 1 + src/mcplocal/src/http/project-mcp-endpoint.ts | 26 +- src/mcplocal/tests/audit-username.test.ts | 116 ++++++++ src/mcplocal/tests/smoke/audit.test.ts | 52 ++++ src/mcplocal/tests/smoke/security.test.ts | 2 +- 17 files changed, 689 insertions(+), 79 deletions(-) create mode 100644 src/db/prisma/migrations/20260304120000_add_audit_username/migration.sql create mode 100644 src/mcpd/tests/auth-me.test.ts create mode 100644 src/mcplocal/tests/audit-username.test.ts diff --git a/src/cli/src/commands/console/audit-app.tsx b/src/cli/src/commands/console/audit-app.tsx index 37cd978..c177fa1 100644 --- a/src/cli/src/commands/console/audit-app.tsx +++ b/src/cli/src/commands/console/audit-app.tsx @@ -4,12 +4,15 @@ * Navigation follows the same patterns as the main unified console: * - Sidebar open: arrows navigate sessions, Enter selects, Escape closes * - Sidebar closed: arrows navigate timeline, Escape reopens sidebar + * + * Sidebar groups sessions by project → user. + * `d` key cycles through date filter presets. */ import { useState, useEffect, useCallback, useRef } from 'react'; import { render, Box, Text, useInput, useApp, useStdout } from 'ink'; -import type { AuditSession, AuditEvent, AuditConsoleState } from './audit-types.js'; -import { EVENT_KIND_COLORS, EVENT_KIND_LABELS } from './audit-types.js'; +import type { AuditSession, AuditEvent, AuditConsoleState, DateFilterPreset } from './audit-types.js'; +import { EVENT_KIND_COLORS, EVENT_KIND_LABELS, DATE_FILTER_CYCLE, DATE_FILTER_LABELS, dateFilterToFrom } from './audit-types.js'; import http from 'node:http'; const POLL_INTERVAL_MS = 3_000; @@ -77,22 +80,130 @@ function formatDetailPayload(payload: Record): string[] { return lines; } +// ── Sidebar grouping ── + +interface SidebarLine { + type: 'project-header' | 'user-header' | 'session'; + label: string; + sessionIdx?: number; // flat index into sessions array (only for type=session) +} + +function buildGroupedLines(sessions: AuditSession[]): SidebarLine[] { + // Group by project → user + const projectMap = new Map>(); + const projectOrder: string[] = []; + + for (let i = 0; i < sessions.length; i++) { + const s = sessions[i]!; + let userMap = projectMap.get(s.projectName); + if (!userMap) { + userMap = new Map(); + projectMap.set(s.projectName, userMap); + projectOrder.push(s.projectName); + } + const userName = s.userName ?? '(unknown)'; + let indices = userMap.get(userName); + if (!indices) { + indices = []; + userMap.set(userName, indices); + } + indices.push(i); + } + + const lines: SidebarLine[] = []; + for (const proj of projectOrder) { + lines.push({ type: 'project-header', label: proj }); + const userMap = projectMap.get(proj)!; + for (const [user, indices] of userMap) { + lines.push({ type: 'user-header', label: user }); + for (const idx of indices) { + const s = sessions[idx]!; + const time = formatTime(s.lastSeen); + lines.push({ + type: 'session', + label: `${s.sessionId.slice(0, 8)} \u00B7 ${s.eventCount} ev \u00B7 ${time}`, + sessionIdx: idx, + }); + } + } + } + return lines; +} + +/** Extract session indices in visual (grouped) order. */ +function visualSessionOrder(sessions: AuditSession[]): number[] { + return buildGroupedLines(sessions) + .filter((l) => l.type === 'session') + .map((l) => l.sessionIdx!); +} + // ── Session Sidebar ── -function AuditSidebar({ sessions, selectedIdx, projectFilter }: { sessions: AuditSession[]; selectedIdx: number; projectFilter: string | null }) { +function AuditSidebar({ sessions, selectedIdx, projectFilter, dateFilter, height }: { + sessions: AuditSession[]; + selectedIdx: number; + projectFilter: string | null; + dateFilter: DateFilterPreset; + height: number; +}) { + const grouped = buildGroupedLines(sessions); + + const headerLines = 4; // title + filter info + blank + "All" row + const footerLines = 0; + const bodyHeight = Math.max(1, height - headerLines - footerLines); + + // Find which render line corresponds to the selected session + let selectedLineIdx = -1; + if (selectedIdx >= 0) { + selectedLineIdx = grouped.findIndex((l) => l.sessionIdx === selectedIdx); + } + + // Scroll to keep selected visible + let scrollStart = 0; + if (selectedLineIdx >= 0) { + if (selectedLineIdx >= scrollStart + bodyHeight) { + scrollStart = selectedLineIdx - bodyHeight + 1; + } + if (selectedLineIdx < scrollStart) { + scrollStart = selectedLineIdx; + } + } + scrollStart = Math.max(0, scrollStart); + + const visibleLines = grouped.slice(scrollStart, scrollStart + bodyHeight); + return ( - - Sessions - {projectFilter ? `project: ${projectFilter}` : 'all projects'} + + Sessions ({sessions.length}) + + {projectFilter ? `project: ${projectFilter}` : 'all projects'} + {dateFilter !== 'all' ? ` \u00B7 ${DATE_FILTER_LABELS[dateFilter]}` : ''} + {selectedIdx === -1 ? '\u25B8 ' : ' '}All ({sessions.reduce((s, x) => s + x.eventCount, 0)} events) - {sessions.map((s, i) => { - const isSel = i === selectedIdx; + + {visibleLines.map((line, vi) => { + if (line.type === 'project-header') { + return ( + + {' '}{trunc(line.label, 28)} + + ); + } + if (line.type === 'user-header') { + return ( + + {' '}{trunc(line.label, 26)} + + ); + } + // session + const isSel = line.sessionIdx === selectedIdx; return ( - - {isSel ? '\u25B8 ' : ' '}{trunc(s.sessionId.slice(0, 12), 12)} {s.projectName} ({s.eventCount}) + + {isSel ? ' \u25B8 ' : ' '}{trunc(line.label, 24)} ); })} @@ -207,6 +318,7 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { detailScrollOffset: 0, projectFilter: projectFilter ?? null, kindFilter: null, + dateFilter: 'all', }); // Use refs for polling to avoid re-creating intervals on every state change @@ -217,8 +329,10 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { const fetchSessions = useCallback(async () => { try { const params = new URLSearchParams(); - const pf = stateRef.current.projectFilter; - if (pf) params.set('projectName', pf); + const s = stateRef.current; + if (s.projectFilter) params.set('projectName', s.projectFilter); + const from = dateFilterToFrom(s.dateFilter); + if (from) params.set('from', from); params.set('limit', '50'); const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`; const data = await fetchJson<{ sessions?: AuditSession[]; total?: number }>(url, token); @@ -248,6 +362,8 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { params.set('projectName', s.projectFilter); } if (s.kindFilter) params.set('eventKind', s.kindFilter); + const from = dateFilterToFrom(s.dateFilter); + if (from) params.set('from', from); params.set('limit', String(MAX_EVENTS)); const url = `${mcpdUrl}/api/v1/audit/events?${params.toString()}`; const data = await fetchJson<{ events?: AuditEvent[]; total?: number }>(url, token); @@ -271,6 +387,32 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { return () => clearInterval(timer); }, [fetchSessions, fetchEvents]); + // Date filter handler — shared between sidebar and timeline + const handleDateFilter = useCallback(() => { + setState((prev) => { + const currentIdx = DATE_FILTER_CYCLE.indexOf(prev.dateFilter); + const nextIdx = (currentIdx + 1) % DATE_FILTER_CYCLE.length; + const next = { ...prev, dateFilter: DATE_FILTER_CYCLE[nextIdx]!, focusedEventIdx: -1, selectedSessionIdx: -1 }; + stateRef.current = next; + return next; + }); + void fetchSessions(); + void fetchEvents(); + }, [fetchSessions, fetchEvents]); + + // Kind filter handler — shared between sidebar and timeline + const handleKindFilter = useCallback(() => { + const kinds = [null, 'tool_call_trace', 'gate_decision', 'pipeline_execution', 'stage_execution', 'prompt_delivery', 'session_bind']; + setState((prev) => { + const currentIdx = kinds.indexOf(prev.kindFilter); + const nextIdx = (currentIdx + 1) % kinds.length; + const next = { ...prev, kindFilter: kinds[nextIdx] ?? null, focusedEventIdx: -1 }; + stateRef.current = next; + return next; + }); + void fetchEvents(); + }, [fetchEvents]); + // Keyboard input useInput((input, key) => { const s = stateRef.current; @@ -310,25 +452,32 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { // ── Sidebar navigation (arrows = sessions, Enter = select, Escape = close) ── if (s.showSidebar) { - if (key.downArrow) { - setState((prev) => ({ - ...prev, - selectedSessionIdx: Math.min(prev.sessions.length - 1, prev.selectedSessionIdx + 1), - focusedEventIdx: -1, - })); - // Re-fetch events for the newly selected session + const navigateSidebar = (direction: number, step: number = 1) => { + setState((prev) => { + const order = visualSessionOrder(prev.sessions); + if (order.length === 0) return prev; + const curPos = prev.selectedSessionIdx === -1 ? -1 : order.indexOf(prev.selectedSessionIdx); + let newPos = curPos + direction * step; + let newIdx: number; + if (newPos < 0) { + newIdx = -1; // "All" selection + } else { + newPos = Math.min(order.length - 1, Math.max(0, newPos)); + newIdx = order[newPos]!; + } + if (newIdx === prev.selectedSessionIdx) return prev; + const next = { ...prev, selectedSessionIdx: newIdx, focusedEventIdx: -1 }; + stateRef.current = next; + return next; + }); void fetchEvents(); - return; - } - if (key.upArrow) { - setState((prev) => ({ - ...prev, - selectedSessionIdx: Math.max(-1, prev.selectedSessionIdx - 1), - focusedEventIdx: -1, - })); - void fetchEvents(); - return; - } + }; + + if (key.downArrow) { navigateSidebar(1); return; } + if (key.upArrow) { navigateSidebar(-1); return; } + if (key.pageDown) { navigateSidebar(1, Math.max(1, Math.floor(stdout.rows * 0.5))); return; } + if (key.pageUp) { navigateSidebar(-1, Math.max(1, Math.floor(stdout.rows * 0.5))); return; } + if (key.return) { // Enter closes sidebar, keeping the selected session setState((prev) => ({ ...prev, showSidebar: false, focusedEventIdx: -1 })); @@ -339,17 +488,8 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { return; } - // Kind filter: k cycles through event kinds - if (input === 'k') { - const kinds = [null, 'tool_call_trace', 'gate_decision', 'pipeline_execution', 'stage_execution', 'prompt_delivery', 'session_bind']; - setState((prev) => { - const currentIdx = kinds.indexOf(prev.kindFilter); - const nextIdx = (currentIdx + 1) % kinds.length; - return { ...prev, kindFilter: kinds[nextIdx] ?? null, focusedEventIdx: -1 }; - }); - void fetchEvents(); - return; - } + if (input === 'k') { handleKindFilter(); return; } + if (input === 'd') { handleDateFilter(); return; } return; // Absorb all other input when sidebar is open } @@ -368,17 +508,8 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { return; } - // Kind filter - if (input === 'k') { - const kinds = [null, 'tool_call_trace', 'gate_decision', 'pipeline_execution', 'stage_execution', 'prompt_delivery', 'session_bind']; - setState((prev) => { - const currentIdx = kinds.indexOf(prev.kindFilter); - const nextIdx = (currentIdx + 1) % kinds.length; - return { ...prev, kindFilter: kinds[nextIdx] ?? null, focusedEventIdx: -1 }; - }); - void fetchEvents(); - return; - } + if (input === 'k') { handleKindFilter(); return; } + if (input === 'd') { handleDateFilter(); return; } // Enter: detail view if (key.return) { @@ -463,10 +594,10 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { // Main view const sidebarHint = state.showSidebar - ? '[\u2191\u2193] session [Enter] select [k] kind [Esc] close [q] quit' + ? '[\u2191\u2193] session [Enter] select [k] kind [d] date [Esc] close [q] quit' : state.focusedEventIdx === -1 - ? '[\u2191] nav [k] kind [Enter] detail [Esc] sidebar [q] quit' - : '[\u2191\u2193] nav [PgUp/Dn] page [a] follow [k] kind [Enter] detail [Esc] sidebar [q] quit'; + ? '[\u2191] nav [k] kind [d] date [Enter] detail [Esc] sidebar [q] quit' + : '[\u2191\u2193] nav [PgUp/Dn] page [a] follow [k] kind [d] date [Enter] detail [Esc] sidebar [q] quit'; return ( @@ -474,13 +605,20 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { Audit Console {state.totalEvents} total events - {state.kindFilter && filter: {EVENT_KIND_LABELS[state.kindFilter] ?? state.kindFilter}} + {state.kindFilter && kind: {EVENT_KIND_LABELS[state.kindFilter] ?? state.kindFilter}} + {state.dateFilter !== 'all' && date: {DATE_FILTER_LABELS[state.dateFilter]}} {/* Body */} {state.showSidebar && ( - + )} diff --git a/src/cli/src/commands/console/audit-types.ts b/src/cli/src/commands/console/audit-types.ts index 90a79a7..23be66f 100644 --- a/src/cli/src/commands/console/audit-types.ts +++ b/src/cli/src/commands/console/audit-types.ts @@ -5,6 +5,7 @@ export interface AuditSession { sessionId: string; projectName: string; + userName: string | null; firstSeen: string; lastSeen: string; eventCount: number; @@ -46,6 +47,34 @@ export interface AuditConsoleState { // Filters projectFilter: string | null; kindFilter: string | null; + dateFilter: 'all' | '1h' | '24h' | '7d' | 'today'; +} + +export type DateFilterPreset = 'all' | '1h' | '24h' | '7d' | 'today'; + +export const DATE_FILTER_CYCLE: DateFilterPreset[] = ['all', 'today', '1h', '24h', '7d']; + +export const DATE_FILTER_LABELS: Record = { + 'all': 'all time', + 'today': 'today', + '1h': 'last hour', + '24h': 'last 24h', + '7d': 'last 7 days', +}; + +export function dateFilterToFrom(preset: DateFilterPreset): string | undefined { + if (preset === 'all') return undefined; + const now = new Date(); + switch (preset) { + case '1h': return new Date(now.getTime() - 60 * 60 * 1000).toISOString(); + case '24h': return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + case '7d': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); + case 'today': { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + return start.toISOString(); + } + } } export const EVENT_KIND_COLORS: Record = { diff --git a/src/db/prisma/migrations/20260304120000_add_audit_username/migration.sql b/src/db/prisma/migrations/20260304120000_add_audit_username/migration.sql new file mode 100644 index 0000000..d457e8c --- /dev/null +++ b/src/db/prisma/migrations/20260304120000_add_audit_username/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "AuditEvent" ADD COLUMN "userName" TEXT; + +-- CreateIndex +CREATE INDEX "AuditEvent_userName_idx" ON "AuditEvent"("userName"); diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index e9e2f9c..6e49e61 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -288,6 +288,7 @@ model AuditEvent { serverName String? correlationId String? parentEventId String? + userName String? payload Json createdAt DateTime @default(now()) @@ -296,6 +297,7 @@ model AuditEvent { @@index([correlationId]) @@index([timestamp]) @@index([eventKind]) + @@index([userName]) } // ── Audit Logs ── diff --git a/src/mcpd/src/repositories/audit-event.repository.ts b/src/mcpd/src/repositories/audit-event.repository.ts index 19f9b9c..f64aaf6 100644 --- a/src/mcpd/src/repositories/audit-event.repository.ts +++ b/src/mcpd/src/repositories/audit-event.repository.ts @@ -29,6 +29,7 @@ export class AuditEventRepository implements IAuditEventRepository { serverName: e.serverName ?? null, correlationId: e.correlationId ?? null, parentEventId: e.parentEventId ?? null, + userName: e.userName ?? null, payload: e.payload as Prisma.InputJsonValue, })); const result = await this.prisma.auditEvent.createMany({ data }); @@ -40,9 +41,16 @@ export class AuditEventRepository implements IAuditEventRepository { return this.prisma.auditEvent.count({ where }); } - async listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise { + async listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise { const where: Prisma.AuditEventWhereInput = {}; if (filter?.projectName !== undefined) where.projectName = filter.projectName; + if (filter?.userName !== undefined) where.userName = filter.userName; + if (filter?.from !== undefined || filter?.to !== undefined) { + const timestamp: Prisma.DateTimeFilter = {}; + if (filter?.from !== undefined) timestamp.gte = filter.from; + if (filter?.to !== undefined) timestamp.lte = filter.to; + where.timestamp = timestamp; + } const groups = await this.prisma.auditEvent.groupBy({ by: ['sessionId', 'projectName'], @@ -55,15 +63,22 @@ export class AuditEventRepository implements IAuditEventRepository { skip: filter?.offset ?? 0, }); - // Fetch distinct eventKinds per session + // Fetch distinct eventKinds + first userName per session const sessionIds = groups.map((g) => g.sessionId); - const kindRows = sessionIds.length > 0 - ? await this.prisma.auditEvent.findMany({ - where: { sessionId: { in: sessionIds } }, - select: { sessionId: true, eventKind: true }, - distinct: ['sessionId', 'eventKind'], - }) - : []; + const [kindRows, userNameRows] = sessionIds.length > 0 + ? await Promise.all([ + this.prisma.auditEvent.findMany({ + where: { sessionId: { in: sessionIds } }, + select: { sessionId: true, eventKind: true }, + distinct: ['sessionId', 'eventKind'], + }), + this.prisma.auditEvent.findMany({ + where: { sessionId: { in: sessionIds }, userName: { not: null } }, + select: { sessionId: true, userName: true }, + distinct: ['sessionId'], + }), + ]) + : [[], []]; const kindMap = new Map(); for (const row of kindRows) { @@ -72,9 +87,15 @@ export class AuditEventRepository implements IAuditEventRepository { kindMap.set(row.sessionId, list); } + const userMap = new Map(); + for (const row of userNameRows) { + if (row.userName) userMap.set(row.sessionId, row.userName); + } + return groups.map((g) => ({ sessionId: g.sessionId, projectName: g.projectName, + userName: userMap.get(g.sessionId) ?? null, firstSeen: g._min.timestamp!, lastSeen: g._max.timestamp!, eventCount: g._count, @@ -82,9 +103,16 @@ export class AuditEventRepository implements IAuditEventRepository { })); } - async countSessions(filter?: { projectName?: string }): Promise { + async countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise { const where: Prisma.AuditEventWhereInput = {}; if (filter?.projectName !== undefined) where.projectName = filter.projectName; + if (filter?.userName !== undefined) where.userName = filter.userName; + if (filter?.from !== undefined || filter?.to !== undefined) { + const timestamp: Prisma.DateTimeFilter = {}; + if (filter?.from !== undefined) timestamp.gte = filter.from; + if (filter?.to !== undefined) timestamp.lte = filter.to; + where.timestamp = timestamp; + } const groups = await this.prisma.auditEvent.groupBy({ by: ['sessionId'], @@ -103,6 +131,7 @@ function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput { if (filter.eventKind !== undefined) where.eventKind = filter.eventKind; if (filter.serverName !== undefined) where.serverName = filter.serverName; if (filter.correlationId !== undefined) where.correlationId = filter.correlationId; + if (filter.userName !== undefined) where.userName = filter.userName; if (filter.from !== undefined || filter.to !== undefined) { const timestamp: Prisma.DateTimeFilter = {}; diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index 98bebbe..53b81b3 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -56,6 +56,7 @@ export interface AuditEventFilter { eventKind?: string; serverName?: string; correlationId?: string; + userName?: string; from?: Date; to?: Date; limit?: number; @@ -72,12 +73,14 @@ export interface AuditEventCreateInput { serverName?: string; correlationId?: string; parentEventId?: string; + userName?: string; payload: Record; } export interface AuditSessionSummary { sessionId: string; projectName: string; + userName: string | null; firstSeen: Date; lastSeen: Date; eventCount: number; @@ -89,6 +92,6 @@ export interface IAuditEventRepository { findById(id: string): Promise; createMany(events: AuditEventCreateInput[]): Promise; count(filter?: AuditEventFilter): Promise; - listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise; - countSessions(filter?: { projectName?: string }): Promise; + listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise; + countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise; } diff --git a/src/mcpd/src/routes/audit-events.ts b/src/mcpd/src/routes/audit-events.ts index 6fca15c..059905a 100644 --- a/src/mcpd/src/routes/audit-events.ts +++ b/src/mcpd/src/routes/audit-events.ts @@ -8,6 +8,7 @@ interface AuditEventQuery { eventKind?: string; serverName?: string; correlationId?: string; + userName?: string; from?: string; to?: string; limit?: string; @@ -45,6 +46,7 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve if (q.eventKind !== undefined) params['eventKind'] = q.eventKind; if (q.serverName !== undefined) params['serverName'] = q.serverName; if (q.correlationId !== undefined) params['correlationId'] = q.correlationId; + if (q.userName !== undefined) params['userName'] = q.userName; if (q.from !== undefined) params['from'] = q.from; if (q.to !== undefined) params['to'] = q.to; if (q.limit !== undefined) params['limit'] = parseInt(q.limit, 10); @@ -58,10 +60,13 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve }); // GET /api/v1/audit/sessions — list sessions with aggregates - app.get<{ Querystring: { projectName?: string; limit?: string; offset?: string } }>('/api/v1/audit/sessions', async (request) => { + app.get<{ Querystring: { projectName?: string; userName?: string; from?: string; to?: string; limit?: string; offset?: string } }>('/api/v1/audit/sessions', async (request) => { const q = request.query; - const params: { projectName?: string; limit?: number; offset?: number } = {}; + const params: { projectName?: string; userName?: string; from?: string; to?: string; limit?: number; offset?: number } = {}; if (q.projectName !== undefined) params.projectName = q.projectName; + if (q.userName !== undefined) params.userName = q.userName; + if (q.from !== undefined) params.from = q.from; + if (q.to !== undefined) params.to = q.to; if (q.limit !== undefined) params.limit = parseInt(q.limit, 10); if (q.offset !== undefined) params.offset = parseInt(q.offset, 10); return service.listSessions(Object.keys(params).length > 0 ? params : undefined); diff --git a/src/mcpd/src/routes/auth.ts b/src/mcpd/src/routes/auth.ts index 7a68420..bca91a0 100644 --- a/src/mcpd/src/routes/auth.ts +++ b/src/mcpd/src/routes/auth.ts @@ -72,6 +72,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v return session; }); + // GET /api/v1/auth/me — returns current user identity + app.get('/api/v1/auth/me', { preHandler: [authMiddleware] }, async (request) => { + const user = await deps.userService.getById(request.userId!); + return { id: user.id, email: user.email, name: user.name ?? null }; + }); + // POST /api/v1/auth/login — no auth required app.post<{ Body: { email: string; password: string }; diff --git a/src/mcpd/src/services/audit-event.service.ts b/src/mcpd/src/services/audit-event.service.ts index a3383d7..43d6793 100644 --- a/src/mcpd/src/services/audit-event.service.ts +++ b/src/mcpd/src/services/audit-event.service.ts @@ -8,6 +8,7 @@ export interface AuditEventQueryParams { eventKind?: string; serverName?: string; correlationId?: string; + userName?: string; from?: string; to?: string; limit?: number; @@ -38,14 +39,20 @@ export class AuditEventService { return this.repo.createMany(events); } - async listSessions(params?: { projectName?: string; limit?: number; offset?: number }): Promise<{ sessions: Awaited>; total: number }> { - const filter: { projectName?: string; limit?: number; offset?: number } = {}; + async listSessions(params?: { projectName?: string; userName?: string; from?: string; to?: string; limit?: number; offset?: number }): Promise<{ sessions: Awaited>; total: number }> { + const filter: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number } = {}; if (params?.projectName !== undefined) filter.projectName = params.projectName; + if (params?.userName !== undefined) filter.userName = params.userName; + if (params?.from !== undefined) filter.from = new Date(params.from); + if (params?.to !== undefined) filter.to = new Date(params.to); if (params?.limit !== undefined) filter.limit = params.limit; if (params?.offset !== undefined) filter.offset = params.offset; - const countFilter: { projectName?: string } = {}; + const countFilter: { projectName?: string; userName?: string; from?: Date; to?: Date } = {}; if (params?.projectName !== undefined) countFilter.projectName = params.projectName; + if (params?.userName !== undefined) countFilter.userName = params.userName; + if (params?.from !== undefined) countFilter.from = new Date(params.from); + if (params?.to !== undefined) countFilter.to = new Date(params.to); const [sessions, total] = await Promise.all([ this.repo.listSessions(Object.keys(filter).length > 0 ? filter : undefined), @@ -63,6 +70,7 @@ export class AuditEventService { if (params.eventKind !== undefined) filter.eventKind = params.eventKind; if (params.serverName !== undefined) filter.serverName = params.serverName; if (params.correlationId !== undefined) filter.correlationId = params.correlationId; + if (params.userName !== undefined) filter.userName = params.userName; if (params.from !== undefined) filter.from = new Date(params.from); if (params.to !== undefined) filter.to = new Date(params.to); if (params.limit !== undefined) filter.limit = params.limit; diff --git a/src/mcpd/tests/audit-event-routes.test.ts b/src/mcpd/tests/audit-event-routes.test.ts index b2b307f..fd6dea5 100644 --- a/src/mcpd/tests/audit-event-routes.test.ts +++ b/src/mcpd/tests/audit-event-routes.test.ts @@ -179,11 +179,12 @@ describe('audit event routes', () => { }); describe('GET /api/v1/audit/sessions', () => { - it('returns session summaries', async () => { + it('returns session summaries with userName', async () => { vi.mocked(repo.listSessions).mockResolvedValue([ { sessionId: 'sess-1', projectName: 'ha-project', + userName: 'michal', firstSeen: new Date('2026-03-01T12:00:00Z'), lastSeen: new Date('2026-03-01T12:05:00Z'), eventCount: 5, @@ -202,6 +203,7 @@ describe('audit event routes', () => { expect(body.sessions).toHaveLength(1); expect(body.sessions[0].sessionId).toBe('sess-1'); expect(body.sessions[0].eventCount).toBe(5); + expect(body.sessions[0].userName).toBe('michal'); expect(body.total).toBe(1); }); @@ -218,6 +220,33 @@ describe('audit event routes', () => { expect(call.projectName).toBe('ha-project'); }); + it('filters by userName', async () => { + vi.mocked(repo.listSessions).mockResolvedValue([]); + vi.mocked(repo.countSessions).mockResolvedValue(0); + + await app.inject({ + method: 'GET', + url: '/api/v1/audit/sessions?userName=michal', + }); + + const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { userName?: string }; + expect(call.userName).toBe('michal'); + }); + + it('filters by date range (from/to)', async () => { + vi.mocked(repo.listSessions).mockResolvedValue([]); + vi.mocked(repo.countSessions).mockResolvedValue(0); + + await app.inject({ + method: 'GET', + url: '/api/v1/audit/sessions?from=2026-03-01&to=2026-03-02', + }); + + const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { from?: Date; to?: Date }; + expect(call.from).toEqual(new Date('2026-03-01')); + expect(call.to).toEqual(new Date('2026-03-02')); + }); + it('supports pagination', async () => { vi.mocked(repo.listSessions).mockResolvedValue([]); vi.mocked(repo.countSessions).mockResolvedValue(10); diff --git a/src/mcpd/tests/auth-me.test.ts b/src/mcpd/tests/auth-me.test.ts new file mode 100644 index 0000000..ee8cdb4 --- /dev/null +++ b/src/mcpd/tests/auth-me.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { registerAuthRoutes } from '../src/routes/auth.js'; +import { errorHandler } from '../src/middleware/error-handler.js'; +import type { SafeUser } from '../src/repositories/user.repository.js'; + +let app: FastifyInstance; + +afterEach(async () => { + if (app) await app.close(); +}); + +function makeSafeUser(overrides?: Partial): SafeUser { + return { + id: 'user-1', + email: 'michal@example.com', + name: 'Michal', + role: 'user', + provider: 'local', + externalId: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function createMockDeps() { + return { + authService: { + login: vi.fn(async () => ({})), + logout: vi.fn(async () => {}), + findSession: vi.fn(async () => null), + impersonate: vi.fn(async () => ({})), + }, + userService: { + count: vi.fn(async () => 1), + create: vi.fn(async () => makeSafeUser()), + list: vi.fn(async () => []), + getById: vi.fn(async () => makeSafeUser()), + getByEmail: vi.fn(async () => makeSafeUser()), + delete: vi.fn(async () => {}), + }, + groupService: { + create: vi.fn(async () => ({})), + list: vi.fn(async () => []), + getById: vi.fn(async () => null), + getByName: vi.fn(async () => null), + update: vi.fn(async () => null), + delete: vi.fn(async () => {}), + }, + rbacDefinitionService: { + create: vi.fn(async () => ({})), + list: vi.fn(async () => []), + getById: vi.fn(async () => null), + getByName: vi.fn(async () => null), + update: vi.fn(async () => null), + delete: vi.fn(async () => {}), + }, + rbacService: { + canAccess: vi.fn(async () => false), + canRunOperation: vi.fn(async () => false), + getPermissions: vi.fn(async () => []), + }, + }; +} + +describe('GET /api/v1/auth/me', () => { + it('returns user identity for authenticated request', async () => { + const deps = createMockDeps(); + deps.authService.findSession.mockResolvedValue({ + userId: 'user-1', + expiresAt: new Date(Date.now() + 86400_000), + }); + deps.userService.getById.mockResolvedValue(makeSafeUser({ id: 'user-1', name: 'Michal', email: 'michal@example.com' })); + + app = Fastify({ logger: false }); + app.setErrorHandler(errorHandler); + registerAuthRoutes(app, deps as never); + await app.ready(); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { authorization: 'Bearer valid-token' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json<{ id: string; email: string; name: string | null }>(); + expect(body.id).toBe('user-1'); + expect(body.email).toBe('michal@example.com'); + expect(body.name).toBe('Michal'); + }); + + it('returns null name when user has no name', async () => { + const deps = createMockDeps(); + deps.authService.findSession.mockResolvedValue({ + userId: 'user-1', + expiresAt: new Date(Date.now() + 86400_000), + }); + deps.userService.getById.mockResolvedValue(makeSafeUser({ name: null })); + + app = Fastify({ logger: false }); + app.setErrorHandler(errorHandler); + registerAuthRoutes(app, deps as never); + await app.ready(); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { authorization: 'Bearer valid-token' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json<{ name: string | null }>().name).toBeNull(); + }); + + it('returns 401 for unauthenticated request', async () => { + const deps = createMockDeps(); + + app = Fastify({ logger: false }); + app.setErrorHandler(errorHandler); + registerAuthRoutes(app, deps as never); + await app.ready(); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + }); + + expect(res.statusCode).toBe(401); + }); + + it('returns 401 for invalid token', async () => { + const deps = createMockDeps(); + deps.authService.findSession.mockResolvedValue(null); + + app = Fastify({ logger: false }); + app.setErrorHandler(errorHandler); + registerAuthRoutes(app, deps as never); + await app.ready(); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { authorization: 'Bearer bad-token' }, + }); + + expect(res.statusCode).toBe(401); + }); +}); diff --git a/src/mcplocal/src/audit/collector.ts b/src/mcplocal/src/audit/collector.ts index ef06776..082faec 100644 --- a/src/mcplocal/src/audit/collector.ts +++ b/src/mcplocal/src/audit/collector.ts @@ -14,6 +14,7 @@ export class AuditCollector { private queue: AuditEvent[] = []; private flushTimer: ReturnType | null = null; private flushing = false; + private sessionUserNames = new Map(); constructor( private readonly mcpdClient: McpdClient, @@ -22,9 +23,19 @@ export class AuditCollector { this.flushTimer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS); } - /** Queue an audit event. Auto-fills projectName. */ + /** Register a userName for a session. All future events for this session auto-fill it. */ + setSessionUserName(sessionId: string, userName: string): void { + this.sessionUserNames.set(sessionId, userName); + } + + /** Queue an audit event. Auto-fills projectName and userName (from session map). */ emit(event: Omit): void { - this.queue.push({ ...event, projectName: this.projectName }); + const enriched: AuditEvent = { ...event, projectName: this.projectName }; + if (!enriched.userName && enriched.sessionId) { + const name = this.sessionUserNames.get(enriched.sessionId); + if (name) enriched.userName = name; + } + this.queue.push(enriched); if (this.queue.length >= BATCH_SIZE) { void this.flush(); } diff --git a/src/mcplocal/src/audit/types.ts b/src/mcplocal/src/audit/types.ts index 280c408..8a17701 100644 --- a/src/mcplocal/src/audit/types.ts +++ b/src/mcplocal/src/audit/types.ts @@ -31,5 +31,6 @@ export interface AuditEvent { serverName?: string; correlationId?: string; parentEventId?: string; + userName?: string; payload: Record; } diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index cfeb833..9628f34 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -37,9 +37,23 @@ interface SessionEntry { const CACHE_TTL_MS = 60_000; // 60 seconds export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: McpdClient, providerRegistry?: ProviderRegistry | null, trafficCapture?: TrafficCapture | null): void { + /** Resolved identity of the mcplocal owner (from credentials). */ + let resolvedUserName: string | null | undefined; // undefined = not yet resolved const projectCache = new Map(); const sessions = new Map(); + /** Resolve the mcplocal owner's userName once from /auth/me using mcplocal's own credentials. */ + async function ensureUserName(): Promise { + if (resolvedUserName !== undefined) return resolvedUserName; + try { + const me = await mcpdClient.get<{ name?: string; email?: string }>('/api/v1/auth/me'); + resolvedUserName = me.name ?? me.email ?? null; + } catch { + resolvedUserName = null; + } + return resolvedUserName; + } + async function getOrCreateRouter(projectName: string, authToken?: string): Promise { const existing = projectCache.get(projectName); const now = Date.now(); @@ -90,6 +104,7 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp } // Wire audit collector (best-effort, non-blocking) + // userName is resolved lazily on first session creation — placeholder here const auditCollector = new AuditCollector(saClient, projectName); router.setAuditCollector(auditCollector); @@ -170,8 +185,17 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp eventType: 'session_created', body: null, }); + + // Resolve userName from mcplocal owner's credentials (best-effort, non-blocking) + const collector = router.getAuditCollector(); + if (collector) { + void ensureUserName().then((name) => { + if (name) collector.setSessionUserName(id, name); + }); + } + // Audit: session_bind - router.getAuditCollector()?.emit({ + collector?.emit({ timestamp: new Date().toISOString(), sessionId: id, eventKind: 'session_bind', diff --git a/src/mcplocal/tests/audit-username.test.ts b/src/mcplocal/tests/audit-username.test.ts new file mode 100644 index 0000000..836055a --- /dev/null +++ b/src/mcplocal/tests/audit-username.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { AuditCollector } from '../src/audit/collector.js'; +import type { McpdClient } from '../src/http/mcpd-client.js'; + +function mockClient(): McpdClient { + return { + post: vi.fn(async () => ({})), + get: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + forward: vi.fn(async () => ({ status: 200, body: {} })), + withHeaders: vi.fn(() => mockClient()), + } as unknown as McpdClient; +} + +describe('AuditCollector userName', () => { + let collector: AuditCollector; + let client: McpdClient; + + afterEach(async () => { + if (collector) await collector.dispose(); + }); + + it('auto-fills userName from session map', async () => { + client = mockClient(); + collector = new AuditCollector(client, 'test-project'); + collector.setSessionUserName('sess-1', 'michal'); + + collector.emit({ + timestamp: new Date().toISOString(), + sessionId: 'sess-1', + eventKind: 'session_bind', + source: 'mcplocal', + verified: true, + payload: {}, + }); + + await collector.flush(); + + const posted = vi.mocked(client.post).mock.calls[0]![1] as Array>; + expect(posted).toHaveLength(1); + expect(posted[0]!['userName']).toBe('michal'); + expect(posted[0]!['projectName']).toBe('test-project'); + }); + + it('does not overwrite explicitly set userName', async () => { + client = mockClient(); + collector = new AuditCollector(client, 'test-project'); + collector.setSessionUserName('sess-1', 'michal'); + + collector.emit({ + timestamp: new Date().toISOString(), + sessionId: 'sess-1', + eventKind: 'tool_call_trace', + source: 'mcplocal', + verified: true, + userName: 'admin', + payload: {}, + }); + + await collector.flush(); + + const posted = vi.mocked(client.post).mock.calls[0]![1] as Array>; + expect(posted[0]!['userName']).toBe('admin'); + }); + + it('leaves userName undefined when no session mapping exists', async () => { + client = mockClient(); + collector = new AuditCollector(client, 'test-project'); + + collector.emit({ + timestamp: new Date().toISOString(), + sessionId: 'sess-unknown', + eventKind: 'gate_decision', + source: 'client', + verified: false, + payload: {}, + }); + + await collector.flush(); + + const posted = vi.mocked(client.post).mock.calls[0]![1] as Array>; + expect(posted[0]!['userName']).toBeUndefined(); + }); + + it('fills userName for multiple sessions independently', async () => { + client = mockClient(); + collector = new AuditCollector(client, 'test-project'); + collector.setSessionUserName('sess-a', 'alice'); + collector.setSessionUserName('sess-b', 'bob'); + + collector.emit({ + timestamp: new Date().toISOString(), + sessionId: 'sess-a', + eventKind: 'session_bind', + source: 'mcplocal', + verified: true, + payload: {}, + }); + collector.emit({ + timestamp: new Date().toISOString(), + sessionId: 'sess-b', + eventKind: 'session_bind', + source: 'mcplocal', + verified: true, + payload: {}, + }); + + await collector.flush(); + + const posted = vi.mocked(client.post).mock.calls[0]![1] as Array>; + expect(posted).toHaveLength(2); + expect(posted[0]!['userName']).toBe('alice'); + expect(posted[1]!['userName']).toBe('bob'); + }); +}); diff --git a/src/mcplocal/tests/smoke/audit.test.ts b/src/mcplocal/tests/smoke/audit.test.ts index c7c7f86..5d5af3e 100644 --- a/src/mcplocal/tests/smoke/audit.test.ts +++ b/src/mcplocal/tests/smoke/audit.test.ts @@ -41,14 +41,30 @@ interface AuditEvent { projectName: string; source: string; verified: boolean; + userName?: string | null; payload: Record; } +interface AuditSession { + sessionId: string; + projectName: string; + userName: string | null; + firstSeen: string; + lastSeen: string; + eventCount: number; + eventKinds: string[]; +} + interface AuditQueryResult { events: AuditEvent[]; total: number; } +interface AuditSessionResult { + sessions: AuditSession[]; + total: number; +} + /** Fetch JSON from mcpd REST API (with auth from credentials). */ function mcpdGet(path: string): Promise { return new Promise((resolve, reject) => { @@ -263,4 +279,40 @@ describe('Smoke: Audit events', () => { expect(result).toHaveProperty('total'); console.log(` ✓ Audit API returned ${result.events.length} events (total: ${result.total})`); }, 10_000); + + it('sessions endpoint returns userName field', async () => { + if (!available) return; + + const result = await mcpdGet( + `/api/v1/audit/sessions?limit=5`, + ); + + expect(result).toHaveProperty('sessions'); + expect(Array.isArray(result.sessions)).toBe(true); + if (result.sessions.length > 0) { + const session = result.sessions[0]!; + // userName should be present in the response (may be null for old sessions) + expect(session).toHaveProperty('userName'); + expect(session).toHaveProperty('projectName'); + expect(session).toHaveProperty('eventCount'); + console.log(` ✓ Session ${session.sessionId.slice(0, 8)} — userName: ${session.userName ?? '(null)'}, events: ${session.eventCount}`); + } else { + console.log(' No sessions available'); + } + }, 10_000); + + it('sessions endpoint supports date range filter', async () => { + if (!available) return; + + // Query with a very recent "from" — should return fewer sessions + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const result = await mcpdGet( + `/api/v1/audit/sessions?from=${oneHourAgo}&limit=50`, + ); + + expect(result).toHaveProperty('sessions'); + expect(Array.isArray(result.sessions)).toBe(true); + expect(result).toHaveProperty('total'); + console.log(` ✓ Sessions in last hour: ${result.sessions.length} (total: ${result.total})`); + }, 10_000); }); diff --git a/src/mcplocal/tests/smoke/security.test.ts b/src/mcplocal/tests/smoke/security.test.ts index a5c1620..c363f7b 100644 --- a/src/mcplocal/tests/smoke/security.test.ts +++ b/src/mcplocal/tests/smoke/security.test.ts @@ -122,7 +122,7 @@ describe('Smoke: Security — mcplocal unauthenticated endpoints', () => { // Should be accessible without auth (documenting the vulnerability) expect(res.status).toBeLessThan(400); console.log(` ⚠ /inspect accessible without auth (status ${res.status})`); - }, 5_000); + }, 10_000); it('/health/detailed leaks system info without authentication', async () => { if (!available) return;