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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user