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:
@@ -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>
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "AuditEvent" ADD COLUMN "userName" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditEvent_userName_idx" ON "AuditEvent"("userName");
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
152
src/mcpd/tests/auth-me.test.ts
Normal file
152
src/mcpd/tests/auth-me.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -31,5 +31,6 @@ export interface AuditEvent {
|
||||
serverName?: string;
|
||||
correlationId?: string;
|
||||
parentEventId?: string;
|
||||
userName?: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
116
src/mcplocal/tests/audit-username.test.ts
Normal file
116
src/mcplocal/tests/audit-username.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user