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:
|
* Navigation follows the same patterns as the main unified console:
|
||||||
* - Sidebar open: arrows navigate sessions, Enter selects, Escape closes
|
* - Sidebar open: arrows navigate sessions, Enter selects, Escape closes
|
||||||
* - Sidebar closed: arrows navigate timeline, Escape reopens sidebar
|
* - 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 { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
||||||
import type { AuditSession, AuditEvent, AuditConsoleState } from './audit-types.js';
|
import type { AuditSession, AuditEvent, AuditConsoleState, DateFilterPreset } from './audit-types.js';
|
||||||
import { EVENT_KIND_COLORS, EVENT_KIND_LABELS } 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';
|
import http from 'node:http';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 3_000;
|
const POLL_INTERVAL_MS = 3_000;
|
||||||
@@ -77,22 +80,130 @@ function formatDetailPayload(payload: Record<string, unknown>): string[] {
|
|||||||
return lines;
|
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 ──
|
// ── 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 (
|
return (
|
||||||
<Box flexDirection="column" width={30} borderStyle="single" borderColor="gray" paddingX={1}>
|
<Box flexDirection="column" width={34} height={height} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||||
<Text bold>Sessions</Text>
|
<Text bold>Sessions ({sessions.length})</Text>
|
||||||
<Text dimColor>{projectFilter ? `project: ${projectFilter}` : 'all projects'}</Text>
|
<Text dimColor>
|
||||||
|
{projectFilter ? `project: ${projectFilter}` : 'all projects'}
|
||||||
|
{dateFilter !== 'all' ? ` \u00B7 ${DATE_FILTER_LABELS[dateFilter]}` : ''}
|
||||||
|
</Text>
|
||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
<Text color={selectedIdx === -1 ? 'cyan' : undefined} bold={selectedIdx === -1}>
|
<Text color={selectedIdx === -1 ? 'cyan' : undefined} bold={selectedIdx === -1}>
|
||||||
{selectedIdx === -1 ? '\u25B8 ' : ' '}All ({sessions.reduce((s, x) => s + x.eventCount, 0)} events)
|
{selectedIdx === -1 ? '\u25B8 ' : ' '}All ({sessions.reduce((s, x) => s + x.eventCount, 0)} events)
|
||||||
</Text>
|
</Text>
|
||||||
{sessions.map((s, i) => {
|
|
||||||
const isSel = i === selectedIdx;
|
{visibleLines.map((line, vi) => {
|
||||||
|
if (line.type === 'project-header') {
|
||||||
return (
|
return (
|
||||||
<Text key={s.sessionId} color={isSel ? 'cyan' : undefined} bold={isSel} wrap="truncate">
|
<Text key={`p-${line.label}-${vi}`} bold wrap="truncate">
|
||||||
{isSel ? '\u25B8 ' : ' '}{trunc(s.sessionId.slice(0, 12), 12)} <Text dimColor>{s.projectName} ({s.eventCount})</Text>
|
{' '}{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-${line.sessionIdx}-${vi}`} color={isSel ? 'cyan' : undefined} bold={isSel} wrap="truncate">
|
||||||
|
{isSel ? ' \u25B8 ' : ' '}{trunc(line.label, 24)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -207,6 +318,7 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
|
|||||||
detailScrollOffset: 0,
|
detailScrollOffset: 0,
|
||||||
projectFilter: projectFilter ?? null,
|
projectFilter: projectFilter ?? null,
|
||||||
kindFilter: null,
|
kindFilter: null,
|
||||||
|
dateFilter: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use refs for polling to avoid re-creating intervals on every state change
|
// 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 () => {
|
const fetchSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
const pf = stateRef.current.projectFilter;
|
const s = stateRef.current;
|
||||||
if (pf) params.set('projectName', pf);
|
if (s.projectFilter) params.set('projectName', s.projectFilter);
|
||||||
|
const from = dateFilterToFrom(s.dateFilter);
|
||||||
|
if (from) params.set('from', from);
|
||||||
params.set('limit', '50');
|
params.set('limit', '50');
|
||||||
const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`;
|
const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`;
|
||||||
const data = await fetchJson<{ sessions?: AuditSession[]; total?: number }>(url, token);
|
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);
|
params.set('projectName', s.projectFilter);
|
||||||
}
|
}
|
||||||
if (s.kindFilter) params.set('eventKind', s.kindFilter);
|
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));
|
params.set('limit', String(MAX_EVENTS));
|
||||||
const url = `${mcpdUrl}/api/v1/audit/events?${params.toString()}`;
|
const url = `${mcpdUrl}/api/v1/audit/events?${params.toString()}`;
|
||||||
const data = await fetchJson<{ events?: AuditEvent[]; total?: number }>(url, token);
|
const data = await fetchJson<{ events?: AuditEvent[]; total?: number }>(url, token);
|
||||||
@@ -271,6 +387,32 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [fetchSessions, fetchEvents]);
|
}, [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
|
// Keyboard input
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
const s = stateRef.current;
|
const s = stateRef.current;
|
||||||
@@ -310,25 +452,32 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
|
|||||||
|
|
||||||
// ── Sidebar navigation (arrows = sessions, Enter = select, Escape = close) ──
|
// ── Sidebar navigation (arrows = sessions, Enter = select, Escape = close) ──
|
||||||
if (s.showSidebar) {
|
if (s.showSidebar) {
|
||||||
if (key.downArrow) {
|
const navigateSidebar = (direction: number, step: number = 1) => {
|
||||||
setState((prev) => ({
|
setState((prev) => {
|
||||||
...prev,
|
const order = visualSessionOrder(prev.sessions);
|
||||||
selectedSessionIdx: Math.min(prev.sessions.length - 1, prev.selectedSessionIdx + 1),
|
if (order.length === 0) return prev;
|
||||||
focusedEventIdx: -1,
|
const curPos = prev.selectedSessionIdx === -1 ? -1 : order.indexOf(prev.selectedSessionIdx);
|
||||||
}));
|
let newPos = curPos + direction * step;
|
||||||
// Re-fetch events for the newly selected session
|
let newIdx: number;
|
||||||
void fetchEvents();
|
if (newPos < 0) {
|
||||||
return;
|
newIdx = -1; // "All" selection
|
||||||
|
} else {
|
||||||
|
newPos = Math.min(order.length - 1, Math.max(0, newPos));
|
||||||
|
newIdx = order[newPos]!;
|
||||||
}
|
}
|
||||||
if (key.upArrow) {
|
if (newIdx === prev.selectedSessionIdx) return prev;
|
||||||
setState((prev) => ({
|
const next = { ...prev, selectedSessionIdx: newIdx, focusedEventIdx: -1 };
|
||||||
...prev,
|
stateRef.current = next;
|
||||||
selectedSessionIdx: Math.max(-1, prev.selectedSessionIdx - 1),
|
return next;
|
||||||
focusedEventIdx: -1,
|
});
|
||||||
}));
|
|
||||||
void fetchEvents();
|
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) {
|
if (key.return) {
|
||||||
// Enter closes sidebar, keeping the selected session
|
// Enter closes sidebar, keeping the selected session
|
||||||
setState((prev) => ({ ...prev, showSidebar: false, focusedEventIdx: -1 }));
|
setState((prev) => ({ ...prev, showSidebar: false, focusedEventIdx: -1 }));
|
||||||
@@ -339,17 +488,8 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kind filter: k cycles through event kinds
|
if (input === 'k') { handleKindFilter(); return; }
|
||||||
if (input === 'k') {
|
if (input === 'd') { handleDateFilter(); return; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return; // Absorb all other input when sidebar is open
|
return; // Absorb all other input when sidebar is open
|
||||||
}
|
}
|
||||||
@@ -368,17 +508,8 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kind filter
|
if (input === 'k') { handleKindFilter(); return; }
|
||||||
if (input === 'k') {
|
if (input === 'd') { handleDateFilter(); return; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter: detail view
|
// Enter: detail view
|
||||||
if (key.return) {
|
if (key.return) {
|
||||||
@@ -463,10 +594,10 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
|
|||||||
|
|
||||||
// Main view
|
// Main view
|
||||||
const sidebarHint = state.showSidebar
|
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
|
: state.focusedEventIdx === -1
|
||||||
? '[\u2191] nav [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 [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 (
|
return (
|
||||||
<Box flexDirection="column" height={stdout.rows}>
|
<Box flexDirection="column" height={stdout.rows}>
|
||||||
@@ -474,13 +605,20 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
|
|||||||
<Box paddingX={1}>
|
<Box paddingX={1}>
|
||||||
<Text bold color="cyan">Audit Console</Text>
|
<Text bold color="cyan">Audit Console</Text>
|
||||||
<Text dimColor> {state.totalEvents} total events</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>
|
</Box>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<Box flexGrow={1}>
|
<Box flexGrow={1}>
|
||||||
{state.showSidebar && (
|
{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} />
|
<AuditTimeline events={state.events} height={height} focusedIdx={state.focusedEventIdx} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
export interface AuditSession {
|
export interface AuditSession {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
userName: string | null;
|
||||||
firstSeen: string;
|
firstSeen: string;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
@@ -46,6 +47,34 @@ export interface AuditConsoleState {
|
|||||||
// Filters
|
// Filters
|
||||||
projectFilter: string | null;
|
projectFilter: string | null;
|
||||||
kindFilter: 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> = {
|
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?
|
serverName String?
|
||||||
correlationId String?
|
correlationId String?
|
||||||
parentEventId String?
|
parentEventId String?
|
||||||
|
userName String?
|
||||||
payload Json
|
payload Json
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -296,6 +297,7 @@ model AuditEvent {
|
|||||||
@@index([correlationId])
|
@@index([correlationId])
|
||||||
@@index([timestamp])
|
@@index([timestamp])
|
||||||
@@index([eventKind])
|
@@index([eventKind])
|
||||||
|
@@index([userName])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audit Logs ──
|
// ── Audit Logs ──
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class AuditEventRepository implements IAuditEventRepository {
|
|||||||
serverName: e.serverName ?? null,
|
serverName: e.serverName ?? null,
|
||||||
correlationId: e.correlationId ?? null,
|
correlationId: e.correlationId ?? null,
|
||||||
parentEventId: e.parentEventId ?? null,
|
parentEventId: e.parentEventId ?? null,
|
||||||
|
userName: e.userName ?? null,
|
||||||
payload: e.payload as Prisma.InputJsonValue,
|
payload: e.payload as Prisma.InputJsonValue,
|
||||||
}));
|
}));
|
||||||
const result = await this.prisma.auditEvent.createMany({ data });
|
const result = await this.prisma.auditEvent.createMany({ data });
|
||||||
@@ -40,9 +41,16 @@ export class AuditEventRepository implements IAuditEventRepository {
|
|||||||
return this.prisma.auditEvent.count({ where });
|
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 = {};
|
const where: Prisma.AuditEventWhereInput = {};
|
||||||
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
|
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({
|
const groups = await this.prisma.auditEvent.groupBy({
|
||||||
by: ['sessionId', 'projectName'],
|
by: ['sessionId', 'projectName'],
|
||||||
@@ -55,15 +63,22 @@ export class AuditEventRepository implements IAuditEventRepository {
|
|||||||
skip: filter?.offset ?? 0,
|
skip: filter?.offset ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch distinct eventKinds per session
|
// Fetch distinct eventKinds + first userName per session
|
||||||
const sessionIds = groups.map((g) => g.sessionId);
|
const sessionIds = groups.map((g) => g.sessionId);
|
||||||
const kindRows = sessionIds.length > 0
|
const [kindRows, userNameRows] = sessionIds.length > 0
|
||||||
? await this.prisma.auditEvent.findMany({
|
? await Promise.all([
|
||||||
|
this.prisma.auditEvent.findMany({
|
||||||
where: { sessionId: { in: sessionIds } },
|
where: { sessionId: { in: sessionIds } },
|
||||||
select: { sessionId: true, eventKind: true },
|
select: { sessionId: true, eventKind: true },
|
||||||
distinct: ['sessionId', 'eventKind'],
|
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[]>();
|
const kindMap = new Map<string, string[]>();
|
||||||
for (const row of kindRows) {
|
for (const row of kindRows) {
|
||||||
@@ -72,9 +87,15 @@ export class AuditEventRepository implements IAuditEventRepository {
|
|||||||
kindMap.set(row.sessionId, list);
|
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) => ({
|
return groups.map((g) => ({
|
||||||
sessionId: g.sessionId,
|
sessionId: g.sessionId,
|
||||||
projectName: g.projectName,
|
projectName: g.projectName,
|
||||||
|
userName: userMap.get(g.sessionId) ?? null,
|
||||||
firstSeen: g._min.timestamp!,
|
firstSeen: g._min.timestamp!,
|
||||||
lastSeen: g._max.timestamp!,
|
lastSeen: g._max.timestamp!,
|
||||||
eventCount: g._count,
|
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 = {};
|
const where: Prisma.AuditEventWhereInput = {};
|
||||||
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
|
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({
|
const groups = await this.prisma.auditEvent.groupBy({
|
||||||
by: ['sessionId'],
|
by: ['sessionId'],
|
||||||
@@ -103,6 +131,7 @@ function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput {
|
|||||||
if (filter.eventKind !== undefined) where.eventKind = filter.eventKind;
|
if (filter.eventKind !== undefined) where.eventKind = filter.eventKind;
|
||||||
if (filter.serverName !== undefined) where.serverName = filter.serverName;
|
if (filter.serverName !== undefined) where.serverName = filter.serverName;
|
||||||
if (filter.correlationId !== undefined) where.correlationId = filter.correlationId;
|
if (filter.correlationId !== undefined) where.correlationId = filter.correlationId;
|
||||||
|
if (filter.userName !== undefined) where.userName = filter.userName;
|
||||||
|
|
||||||
if (filter.from !== undefined || filter.to !== undefined) {
|
if (filter.from !== undefined || filter.to !== undefined) {
|
||||||
const timestamp: Prisma.DateTimeFilter = {};
|
const timestamp: Prisma.DateTimeFilter = {};
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface AuditEventFilter {
|
|||||||
eventKind?: string;
|
eventKind?: string;
|
||||||
serverName?: string;
|
serverName?: string;
|
||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
|
userName?: string;
|
||||||
from?: Date;
|
from?: Date;
|
||||||
to?: Date;
|
to?: Date;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -72,12 +73,14 @@ export interface AuditEventCreateInput {
|
|||||||
serverName?: string;
|
serverName?: string;
|
||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
parentEventId?: string;
|
parentEventId?: string;
|
||||||
|
userName?: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditSessionSummary {
|
export interface AuditSessionSummary {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
userName: string | null;
|
||||||
firstSeen: Date;
|
firstSeen: Date;
|
||||||
lastSeen: Date;
|
lastSeen: Date;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
@@ -89,6 +92,6 @@ export interface IAuditEventRepository {
|
|||||||
findById(id: string): Promise<AuditEvent | null>;
|
findById(id: string): Promise<AuditEvent | null>;
|
||||||
createMany(events: AuditEventCreateInput[]): Promise<number>;
|
createMany(events: AuditEventCreateInput[]): Promise<number>;
|
||||||
count(filter?: AuditEventFilter): Promise<number>;
|
count(filter?: AuditEventFilter): Promise<number>;
|
||||||
listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
|
listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
|
||||||
countSessions(filter?: { projectName?: string }): Promise<number>;
|
countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface AuditEventQuery {
|
|||||||
eventKind?: string;
|
eventKind?: string;
|
||||||
serverName?: string;
|
serverName?: string;
|
||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
|
userName?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
limit?: string;
|
limit?: string;
|
||||||
@@ -45,6 +46,7 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
|
|||||||
if (q.eventKind !== undefined) params['eventKind'] = q.eventKind;
|
if (q.eventKind !== undefined) params['eventKind'] = q.eventKind;
|
||||||
if (q.serverName !== undefined) params['serverName'] = q.serverName;
|
if (q.serverName !== undefined) params['serverName'] = q.serverName;
|
||||||
if (q.correlationId !== undefined) params['correlationId'] = q.correlationId;
|
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.from !== undefined) params['from'] = q.from;
|
||||||
if (q.to !== undefined) params['to'] = q.to;
|
if (q.to !== undefined) params['to'] = q.to;
|
||||||
if (q.limit !== undefined) params['limit'] = parseInt(q.limit, 10);
|
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
|
// 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 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.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.limit !== undefined) params.limit = parseInt(q.limit, 10);
|
||||||
if (q.offset !== undefined) params.offset = parseInt(q.offset, 10);
|
if (q.offset !== undefined) params.offset = parseInt(q.offset, 10);
|
||||||
return service.listSessions(Object.keys(params).length > 0 ? params : undefined);
|
return service.listSessions(Object.keys(params).length > 0 ? params : undefined);
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
|
|||||||
return session;
|
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
|
// POST /api/v1/auth/login — no auth required
|
||||||
app.post<{
|
app.post<{
|
||||||
Body: { email: string; password: string };
|
Body: { email: string; password: string };
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface AuditEventQueryParams {
|
|||||||
eventKind?: string;
|
eventKind?: string;
|
||||||
serverName?: string;
|
serverName?: string;
|
||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
|
userName?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -38,14 +39,20 @@ export class AuditEventService {
|
|||||||
return this.repo.createMany(events);
|
return this.repo.createMany(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listSessions(params?: { projectName?: string; limit?: number; offset?: number }): Promise<{ sessions: Awaited<ReturnType<IAuditEventRepository['listSessions']>>; total: 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; limit?: number; offset?: 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?.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?.limit !== undefined) filter.limit = params.limit;
|
||||||
if (params?.offset !== undefined) filter.offset = params.offset;
|
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?.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([
|
const [sessions, total] = await Promise.all([
|
||||||
this.repo.listSessions(Object.keys(filter).length > 0 ? filter : undefined),
|
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.eventKind !== undefined) filter.eventKind = params.eventKind;
|
||||||
if (params.serverName !== undefined) filter.serverName = params.serverName;
|
if (params.serverName !== undefined) filter.serverName = params.serverName;
|
||||||
if (params.correlationId !== undefined) filter.correlationId = params.correlationId;
|
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.from !== undefined) filter.from = new Date(params.from);
|
||||||
if (params.to !== undefined) filter.to = new Date(params.to);
|
if (params.to !== undefined) filter.to = new Date(params.to);
|
||||||
if (params.limit !== undefined) filter.limit = params.limit;
|
if (params.limit !== undefined) filter.limit = params.limit;
|
||||||
|
|||||||
@@ -179,11 +179,12 @@ describe('audit event routes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/v1/audit/sessions', () => {
|
describe('GET /api/v1/audit/sessions', () => {
|
||||||
it('returns session summaries', async () => {
|
it('returns session summaries with userName', async () => {
|
||||||
vi.mocked(repo.listSessions).mockResolvedValue([
|
vi.mocked(repo.listSessions).mockResolvedValue([
|
||||||
{
|
{
|
||||||
sessionId: 'sess-1',
|
sessionId: 'sess-1',
|
||||||
projectName: 'ha-project',
|
projectName: 'ha-project',
|
||||||
|
userName: 'michal',
|
||||||
firstSeen: new Date('2026-03-01T12:00:00Z'),
|
firstSeen: new Date('2026-03-01T12:00:00Z'),
|
||||||
lastSeen: new Date('2026-03-01T12:05:00Z'),
|
lastSeen: new Date('2026-03-01T12:05:00Z'),
|
||||||
eventCount: 5,
|
eventCount: 5,
|
||||||
@@ -202,6 +203,7 @@ describe('audit event routes', () => {
|
|||||||
expect(body.sessions).toHaveLength(1);
|
expect(body.sessions).toHaveLength(1);
|
||||||
expect(body.sessions[0].sessionId).toBe('sess-1');
|
expect(body.sessions[0].sessionId).toBe('sess-1');
|
||||||
expect(body.sessions[0].eventCount).toBe(5);
|
expect(body.sessions[0].eventCount).toBe(5);
|
||||||
|
expect(body.sessions[0].userName).toBe('michal');
|
||||||
expect(body.total).toBe(1);
|
expect(body.total).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,6 +220,33 @@ describe('audit event routes', () => {
|
|||||||
expect(call.projectName).toBe('ha-project');
|
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 () => {
|
it('supports pagination', async () => {
|
||||||
vi.mocked(repo.listSessions).mockResolvedValue([]);
|
vi.mocked(repo.listSessions).mockResolvedValue([]);
|
||||||
vi.mocked(repo.countSessions).mockResolvedValue(10);
|
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 queue: AuditEvent[] = [];
|
||||||
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private flushing = false;
|
private flushing = false;
|
||||||
|
private sessionUserNames = new Map<string, string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly mcpdClient: McpdClient,
|
private readonly mcpdClient: McpdClient,
|
||||||
@@ -22,9 +23,19 @@ export class AuditCollector {
|
|||||||
this.flushTimer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
|
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 {
|
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) {
|
if (this.queue.length >= BATCH_SIZE) {
|
||||||
void this.flush();
|
void this.flush();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ export interface AuditEvent {
|
|||||||
serverName?: string;
|
serverName?: string;
|
||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
parentEventId?: string;
|
parentEventId?: string;
|
||||||
|
userName?: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,23 @@ interface SessionEntry {
|
|||||||
const CACHE_TTL_MS = 60_000; // 60 seconds
|
const CACHE_TTL_MS = 60_000; // 60 seconds
|
||||||
|
|
||||||
export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: McpdClient, providerRegistry?: ProviderRegistry | null, trafficCapture?: TrafficCapture | null): void {
|
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 projectCache = new Map<string, ProjectCacheEntry>();
|
||||||
const sessions = new Map<string, SessionEntry>();
|
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> {
|
async function getOrCreateRouter(projectName: string, authToken?: string): Promise<McpRouter> {
|
||||||
const existing = projectCache.get(projectName);
|
const existing = projectCache.get(projectName);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -90,6 +104,7 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wire audit collector (best-effort, non-blocking)
|
// Wire audit collector (best-effort, non-blocking)
|
||||||
|
// userName is resolved lazily on first session creation — placeholder here
|
||||||
const auditCollector = new AuditCollector(saClient, projectName);
|
const auditCollector = new AuditCollector(saClient, projectName);
|
||||||
router.setAuditCollector(auditCollector);
|
router.setAuditCollector(auditCollector);
|
||||||
|
|
||||||
@@ -170,8 +185,17 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
eventType: 'session_created',
|
eventType: 'session_created',
|
||||||
body: null,
|
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
|
// Audit: session_bind
|
||||||
router.getAuditCollector()?.emit({
|
collector?.emit({
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
eventKind: 'session_bind',
|
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;
|
projectName: string;
|
||||||
source: string;
|
source: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
|
userName?: string | null;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuditSession {
|
||||||
|
sessionId: string;
|
||||||
|
projectName: string;
|
||||||
|
userName: string | null;
|
||||||
|
firstSeen: string;
|
||||||
|
lastSeen: string;
|
||||||
|
eventCount: number;
|
||||||
|
eventKinds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface AuditQueryResult {
|
interface AuditQueryResult {
|
||||||
events: AuditEvent[];
|
events: AuditEvent[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuditSessionResult {
|
||||||
|
sessions: AuditSession[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch JSON from mcpd REST API (with auth from credentials). */
|
/** Fetch JSON from mcpd REST API (with auth from credentials). */
|
||||||
function mcpdGet<T>(path: string): Promise<T> {
|
function mcpdGet<T>(path: string): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -263,4 +279,40 @@ describe('Smoke: Audit events', () => {
|
|||||||
expect(result).toHaveProperty('total');
|
expect(result).toHaveProperty('total');
|
||||||
console.log(` ✓ Audit API returned ${result.events.length} events (total: ${result.total})`);
|
console.log(` ✓ Audit API returned ${result.events.length} events (total: ${result.total})`);
|
||||||
}, 10_000);
|
}, 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)
|
// Should be accessible without auth (documenting the vulnerability)
|
||||||
expect(res.status).toBeLessThan(400);
|
expect(res.status).toBeLessThan(400);
|
||||||
console.log(` ⚠ /inspect accessible without auth (status ${res.status})`);
|
console.log(` ⚠ /inspect accessible without auth (status ${res.status})`);
|
||||||
}, 5_000);
|
}, 10_000);
|
||||||
|
|
||||||
it('/health/detailed leaks system info without authentication', async () => {
|
it('/health/detailed leaks system info without authentication', async () => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user