feat: add userName tracking to audit events

- 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 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-07 00:18:58 +00:00
parent 75c44e4ba1
commit 86c5a61eaa
17 changed files with 689 additions and 79 deletions

View File

@@ -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, unknown>): 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<string, Map<string, number[]>>();
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 (
<Box flexDirection="column" width={30} borderStyle="single" borderColor="gray" paddingX={1}>
<Text bold>Sessions</Text>
<Text dimColor>{projectFilter ? `project: ${projectFilter}` : 'all projects'}</Text>
<Box flexDirection="column" width={34} height={height} borderStyle="single" borderColor="gray" paddingX={1}>
<Text bold>Sessions ({sessions.length})</Text>
<Text dimColor>
{projectFilter ? `project: ${projectFilter}` : 'all projects'}
{dateFilter !== 'all' ? ` \u00B7 ${DATE_FILTER_LABELS[dateFilter]}` : ''}
</Text>
<Text> </Text>
<Text color={selectedIdx === -1 ? 'cyan' : undefined} bold={selectedIdx === -1}>
{selectedIdx === -1 ? '\u25B8 ' : ' '}All ({sessions.reduce((s, x) => s + x.eventCount, 0)} events)
</Text>
{sessions.map((s, i) => {
const isSel = i === selectedIdx;
{visibleLines.map((line, vi) => {
if (line.type === 'project-header') {
return (
<Text key={`p-${line.label}-${vi}`} bold wrap="truncate">
{' '}{trunc(line.label, 28)}
</Text>
);
}
if (line.type === 'user-header') {
return (
<Text key={`u-${line.label}-${vi}`} dimColor wrap="truncate">
{' '}{trunc(line.label, 26)}
</Text>
);
}
// session
const isSel = line.sessionIdx === selectedIdx;
return (
<Text key={s.sessionId} color={isSel ? 'cyan' : undefined} bold={isSel} wrap="truncate">
{isSel ? '\u25B8 ' : ' '}{trunc(s.sessionId.slice(0, 12), 12)} <Text dimColor>{s.projectName} ({s.eventCount})</Text>
<Text key={`s-${line.sessionIdx}-${vi}`} color={isSel ? 'cyan' : undefined} bold={isSel} wrap="truncate">
{isSel ? ' \u25B8 ' : ' '}{trunc(line.label, 24)}
</Text>
);
})}
@@ -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 (
<Box flexDirection="column" height={stdout.rows}>
@@ -474,13 +605,20 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
<Box paddingX={1}>
<Text bold color="cyan">Audit Console</Text>
<Text dimColor> {state.totalEvents} total events</Text>
{state.kindFilter && <Text color="yellow"> filter: {EVENT_KIND_LABELS[state.kindFilter] ?? state.kindFilter}</Text>}
{state.kindFilter && <Text color="yellow"> kind: {EVENT_KIND_LABELS[state.kindFilter] ?? state.kindFilter}</Text>}
{state.dateFilter !== 'all' && <Text color="magenta"> date: {DATE_FILTER_LABELS[state.dateFilter]}</Text>}
</Box>
{/* Body */}
<Box flexGrow={1}>
{state.showSidebar && (
<AuditSidebar sessions={state.sessions} selectedIdx={state.selectedSessionIdx} projectFilter={state.projectFilter} />
<AuditSidebar
sessions={state.sessions}
selectedIdx={state.selectedSessionIdx}
projectFilter={state.projectFilter}
dateFilter={state.dateFilter}
height={height}
/>
)}
<AuditTimeline events={state.events} height={height} focusedIdx={state.focusedEventIdx} />
</Box>

View File

@@ -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<DateFilterPreset, string> = {
'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<string, string> = {

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "AuditEvent" ADD COLUMN "userName" TEXT;
-- CreateIndex
CREATE INDEX "AuditEvent_userName_idx" ON "AuditEvent"("userName");

View File

@@ -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 ──

View File

@@ -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<AuditSessionSummary[]> {
async listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]> {
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<string, string[]>();
for (const row of kindRows) {
@@ -72,9 +87,15 @@ export class AuditEventRepository implements IAuditEventRepository {
kindMap.set(row.sessionId, list);
}
const userMap = new Map<string, string>();
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<number> {
async countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number> {
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 = {};

View File

@@ -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<string, unknown>;
}
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<AuditEvent | null>;
createMany(events: AuditEventCreateInput[]): Promise<number>;
count(filter?: AuditEventFilter): Promise<number>;
listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
countSessions(filter?: { projectName?: string }): Promise<number>;
listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number>;
}

View File

@@ -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);

View File

@@ -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 };

View File

@@ -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<ReturnType<IAuditEventRepository['listSessions']>>; 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<ReturnType<IAuditEventRepository['listSessions']>>; 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;

View File

@@ -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);

View File

@@ -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>): 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);
});
});

View File

@@ -14,6 +14,7 @@ export class AuditCollector {
private queue: AuditEvent[] = [];
private flushTimer: ReturnType<typeof setInterval> | null = null;
private flushing = false;
private sessionUserNames = new Map<string, string>();
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<AuditEvent, 'projectName'>): 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();
}

View File

@@ -31,5 +31,6 @@ export interface AuditEvent {
serverName?: string;
correlationId?: string;
parentEventId?: string;
userName?: string;
payload: Record<string, unknown>;
}

View File

@@ -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<string, ProjectCacheEntry>();
const sessions = new Map<string, SessionEntry>();
/** Resolve the mcplocal owner's userName once from /auth/me using mcplocal's own credentials. */
async function ensureUserName(): Promise<string | null> {
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<McpRouter> {
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',

View File

@@ -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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
expect(posted).toHaveLength(2);
expect(posted[0]!['userName']).toBe('alice');
expect(posted[1]!['userName']).toBe('bob');
});
});

View File

@@ -41,14 +41,30 @@ interface AuditEvent {
projectName: string;
source: string;
verified: boolean;
userName?: string | null;
payload: Record<string, unknown>;
}
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<T>(path: string): Promise<T> {
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<AuditSessionResult>(
`/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<AuditSessionResult>(
`/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);
});

View File

@@ -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;