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