feat: add userName tracking to audit events

- Add userName column to AuditEvent schema with index and migration
- Add GET /api/v1/auth/me endpoint returning current user identity
- AuditCollector auto-fills userName from session→user map, resolved
  lazily via /auth/me on first session creation
- Support userName and date range (from/to) filtering on audit events
  and sessions endpoints
- Audit console sidebar groups sessions by project → user
- Add date filter presets (d key: all/today/1h/24h/7d) to console
- Add scrolling and page up/down to sidebar navigation
- Tests: auth-me (4), audit-username collector (4), route filters (2),
  smoke tests (2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-07 00:18:58 +00:00
parent 75c44e4ba1
commit 86c5a61eaa
17 changed files with 689 additions and 79 deletions

View File

@@ -29,6 +29,7 @@ export class AuditEventRepository implements IAuditEventRepository {
serverName: e.serverName ?? null,
correlationId: e.correlationId ?? null,
parentEventId: e.parentEventId ?? null,
userName: e.userName ?? null,
payload: e.payload as Prisma.InputJsonValue,
}));
const result = await this.prisma.auditEvent.createMany({ data });
@@ -40,9 +41,16 @@ export class AuditEventRepository implements IAuditEventRepository {
return this.prisma.auditEvent.count({ where });
}
async listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise<AuditSessionSummary[]> {
async listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]> {
const where: Prisma.AuditEventWhereInput = {};
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
if (filter?.userName !== undefined) where.userName = filter.userName;
if (filter?.from !== undefined || filter?.to !== undefined) {
const timestamp: Prisma.DateTimeFilter = {};
if (filter?.from !== undefined) timestamp.gte = filter.from;
if (filter?.to !== undefined) timestamp.lte = filter.to;
where.timestamp = timestamp;
}
const groups = await this.prisma.auditEvent.groupBy({
by: ['sessionId', 'projectName'],
@@ -55,15 +63,22 @@ export class AuditEventRepository implements IAuditEventRepository {
skip: filter?.offset ?? 0,
});
// Fetch distinct eventKinds per session
// Fetch distinct eventKinds + first userName per session
const sessionIds = groups.map((g) => g.sessionId);
const kindRows = sessionIds.length > 0
? await this.prisma.auditEvent.findMany({
where: { sessionId: { in: sessionIds } },
select: { sessionId: true, eventKind: true },
distinct: ['sessionId', 'eventKind'],
})
: [];
const [kindRows, userNameRows] = sessionIds.length > 0
? await Promise.all([
this.prisma.auditEvent.findMany({
where: { sessionId: { in: sessionIds } },
select: { sessionId: true, eventKind: true },
distinct: ['sessionId', 'eventKind'],
}),
this.prisma.auditEvent.findMany({
where: { sessionId: { in: sessionIds }, userName: { not: null } },
select: { sessionId: true, userName: true },
distinct: ['sessionId'],
}),
])
: [[], []];
const kindMap = new Map<string, string[]>();
for (const row of kindRows) {
@@ -72,9 +87,15 @@ export class AuditEventRepository implements IAuditEventRepository {
kindMap.set(row.sessionId, list);
}
const userMap = new Map<string, string>();
for (const row of userNameRows) {
if (row.userName) userMap.set(row.sessionId, row.userName);
}
return groups.map((g) => ({
sessionId: g.sessionId,
projectName: g.projectName,
userName: userMap.get(g.sessionId) ?? null,
firstSeen: g._min.timestamp!,
lastSeen: g._max.timestamp!,
eventCount: g._count,
@@ -82,9 +103,16 @@ export class AuditEventRepository implements IAuditEventRepository {
}));
}
async countSessions(filter?: { projectName?: string }): Promise<number> {
async countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number> {
const where: Prisma.AuditEventWhereInput = {};
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
if (filter?.userName !== undefined) where.userName = filter.userName;
if (filter?.from !== undefined || filter?.to !== undefined) {
const timestamp: Prisma.DateTimeFilter = {};
if (filter?.from !== undefined) timestamp.gte = filter.from;
if (filter?.to !== undefined) timestamp.lte = filter.to;
where.timestamp = timestamp;
}
const groups = await this.prisma.auditEvent.groupBy({
by: ['sessionId'],
@@ -103,6 +131,7 @@ function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput {
if (filter.eventKind !== undefined) where.eventKind = filter.eventKind;
if (filter.serverName !== undefined) where.serverName = filter.serverName;
if (filter.correlationId !== undefined) where.correlationId = filter.correlationId;
if (filter.userName !== undefined) where.userName = filter.userName;
if (filter.from !== undefined || filter.to !== undefined) {
const timestamp: Prisma.DateTimeFilter = {};

View File

@@ -56,6 +56,7 @@ export interface AuditEventFilter {
eventKind?: string;
serverName?: string;
correlationId?: string;
userName?: string;
from?: Date;
to?: Date;
limit?: number;
@@ -72,12 +73,14 @@ export interface AuditEventCreateInput {
serverName?: string;
correlationId?: string;
parentEventId?: string;
userName?: string;
payload: Record<string, unknown>;
}
export interface AuditSessionSummary {
sessionId: string;
projectName: string;
userName: string | null;
firstSeen: Date;
lastSeen: Date;
eventCount: number;
@@ -89,6 +92,6 @@ export interface IAuditEventRepository {
findById(id: string): Promise<AuditEvent | null>;
createMany(events: AuditEventCreateInput[]): Promise<number>;
count(filter?: AuditEventFilter): Promise<number>;
listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
countSessions(filter?: { projectName?: string }): Promise<number>;
listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number>;
}

View File

@@ -8,6 +8,7 @@ interface AuditEventQuery {
eventKind?: string;
serverName?: string;
correlationId?: string;
userName?: string;
from?: string;
to?: string;
limit?: string;
@@ -45,6 +46,7 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
if (q.eventKind !== undefined) params['eventKind'] = q.eventKind;
if (q.serverName !== undefined) params['serverName'] = q.serverName;
if (q.correlationId !== undefined) params['correlationId'] = q.correlationId;
if (q.userName !== undefined) params['userName'] = q.userName;
if (q.from !== undefined) params['from'] = q.from;
if (q.to !== undefined) params['to'] = q.to;
if (q.limit !== undefined) params['limit'] = parseInt(q.limit, 10);
@@ -58,10 +60,13 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
});
// GET /api/v1/audit/sessions — list sessions with aggregates
app.get<{ Querystring: { projectName?: string; limit?: string; offset?: string } }>('/api/v1/audit/sessions', async (request) => {
app.get<{ Querystring: { projectName?: string; userName?: string; from?: string; to?: string; limit?: string; offset?: string } }>('/api/v1/audit/sessions', async (request) => {
const q = request.query;
const params: { projectName?: string; limit?: number; offset?: number } = {};
const params: { projectName?: string; userName?: string; from?: string; to?: string; limit?: number; offset?: number } = {};
if (q.projectName !== undefined) params.projectName = q.projectName;
if (q.userName !== undefined) params.userName = q.userName;
if (q.from !== undefined) params.from = q.from;
if (q.to !== undefined) params.to = q.to;
if (q.limit !== undefined) params.limit = parseInt(q.limit, 10);
if (q.offset !== undefined) params.offset = parseInt(q.offset, 10);
return service.listSessions(Object.keys(params).length > 0 ? params : undefined);

View File

@@ -72,6 +72,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
return session;
});
// GET /api/v1/auth/me — returns current user identity
app.get('/api/v1/auth/me', { preHandler: [authMiddleware] }, async (request) => {
const user = await deps.userService.getById(request.userId!);
return { id: user.id, email: user.email, name: user.name ?? null };
});
// POST /api/v1/auth/login — no auth required
app.post<{
Body: { email: string; password: string };

View File

@@ -8,6 +8,7 @@ export interface AuditEventQueryParams {
eventKind?: string;
serverName?: string;
correlationId?: string;
userName?: string;
from?: string;
to?: string;
limit?: number;
@@ -38,14 +39,20 @@ export class AuditEventService {
return this.repo.createMany(events);
}
async listSessions(params?: { projectName?: string; limit?: number; offset?: number }): Promise<{ sessions: Awaited<ReturnType<IAuditEventRepository['listSessions']>>; total: number }> {
const filter: { projectName?: string; limit?: number; offset?: number } = {};
async listSessions(params?: { projectName?: string; userName?: string; from?: string; to?: string; limit?: number; offset?: number }): Promise<{ sessions: Awaited<ReturnType<IAuditEventRepository['listSessions']>>; total: number }> {
const filter: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number } = {};
if (params?.projectName !== undefined) filter.projectName = params.projectName;
if (params?.userName !== undefined) filter.userName = params.userName;
if (params?.from !== undefined) filter.from = new Date(params.from);
if (params?.to !== undefined) filter.to = new Date(params.to);
if (params?.limit !== undefined) filter.limit = params.limit;
if (params?.offset !== undefined) filter.offset = params.offset;
const countFilter: { projectName?: string } = {};
const countFilter: { projectName?: string; userName?: string; from?: Date; to?: Date } = {};
if (params?.projectName !== undefined) countFilter.projectName = params.projectName;
if (params?.userName !== undefined) countFilter.userName = params.userName;
if (params?.from !== undefined) countFilter.from = new Date(params.from);
if (params?.to !== undefined) countFilter.to = new Date(params.to);
const [sessions, total] = await Promise.all([
this.repo.listSessions(Object.keys(filter).length > 0 ? filter : undefined),
@@ -63,6 +70,7 @@ export class AuditEventService {
if (params.eventKind !== undefined) filter.eventKind = params.eventKind;
if (params.serverName !== undefined) filter.serverName = params.serverName;
if (params.correlationId !== undefined) filter.correlationId = params.correlationId;
if (params.userName !== undefined) filter.userName = params.userName;
if (params.from !== undefined) filter.from = new Date(params.from);
if (params.to !== undefined) filter.to = new Date(params.to);
if (params.limit !== undefined) filter.limit = params.limit;

View File

@@ -179,11 +179,12 @@ describe('audit event routes', () => {
});
describe('GET /api/v1/audit/sessions', () => {
it('returns session summaries', async () => {
it('returns session summaries with userName', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([
{
sessionId: 'sess-1',
projectName: 'ha-project',
userName: 'michal',
firstSeen: new Date('2026-03-01T12:00:00Z'),
lastSeen: new Date('2026-03-01T12:05:00Z'),
eventCount: 5,
@@ -202,6 +203,7 @@ describe('audit event routes', () => {
expect(body.sessions).toHaveLength(1);
expect(body.sessions[0].sessionId).toBe('sess-1');
expect(body.sessions[0].eventCount).toBe(5);
expect(body.sessions[0].userName).toBe('michal');
expect(body.total).toBe(1);
});
@@ -218,6 +220,33 @@ describe('audit event routes', () => {
expect(call.projectName).toBe('ha-project');
});
it('filters by userName', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([]);
vi.mocked(repo.countSessions).mockResolvedValue(0);
await app.inject({
method: 'GET',
url: '/api/v1/audit/sessions?userName=michal',
});
const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { userName?: string };
expect(call.userName).toBe('michal');
});
it('filters by date range (from/to)', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([]);
vi.mocked(repo.countSessions).mockResolvedValue(0);
await app.inject({
method: 'GET',
url: '/api/v1/audit/sessions?from=2026-03-01&to=2026-03-02',
});
const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { from?: Date; to?: Date };
expect(call.from).toEqual(new Date('2026-03-01'));
expect(call.to).toEqual(new Date('2026-03-02'));
});
it('supports pagination', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([]);
vi.mocked(repo.countSessions).mockResolvedValue(10);

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerAuthRoutes } from '../src/routes/auth.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { SafeUser } from '../src/repositories/user.repository.js';
let app: FastifyInstance;
afterEach(async () => {
if (app) await app.close();
});
function makeSafeUser(overrides?: Partial<SafeUser>): SafeUser {
return {
id: 'user-1',
email: 'michal@example.com',
name: 'Michal',
role: 'user',
provider: 'local',
externalId: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockDeps() {
return {
authService: {
login: vi.fn(async () => ({})),
logout: vi.fn(async () => {}),
findSession: vi.fn(async () => null),
impersonate: vi.fn(async () => ({})),
},
userService: {
count: vi.fn(async () => 1),
create: vi.fn(async () => makeSafeUser()),
list: vi.fn(async () => []),
getById: vi.fn(async () => makeSafeUser()),
getByEmail: vi.fn(async () => makeSafeUser()),
delete: vi.fn(async () => {}),
},
groupService: {
create: vi.fn(async () => ({})),
list: vi.fn(async () => []),
getById: vi.fn(async () => null),
getByName: vi.fn(async () => null),
update: vi.fn(async () => null),
delete: vi.fn(async () => {}),
},
rbacDefinitionService: {
create: vi.fn(async () => ({})),
list: vi.fn(async () => []),
getById: vi.fn(async () => null),
getByName: vi.fn(async () => null),
update: vi.fn(async () => null),
delete: vi.fn(async () => {}),
},
rbacService: {
canAccess: vi.fn(async () => false),
canRunOperation: vi.fn(async () => false),
getPermissions: vi.fn(async () => []),
},
};
}
describe('GET /api/v1/auth/me', () => {
it('returns user identity for authenticated request', async () => {
const deps = createMockDeps();
deps.authService.findSession.mockResolvedValue({
userId: 'user-1',
expiresAt: new Date(Date.now() + 86400_000),
});
deps.userService.getById.mockResolvedValue(makeSafeUser({ id: 'user-1', name: 'Michal', email: 'michal@example.com' }));
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerAuthRoutes(app, deps as never);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
const body = res.json<{ id: string; email: string; name: string | null }>();
expect(body.id).toBe('user-1');
expect(body.email).toBe('michal@example.com');
expect(body.name).toBe('Michal');
});
it('returns null name when user has no name', async () => {
const deps = createMockDeps();
deps.authService.findSession.mockResolvedValue({
userId: 'user-1',
expiresAt: new Date(Date.now() + 86400_000),
});
deps.userService.getById.mockResolvedValue(makeSafeUser({ name: null }));
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerAuthRoutes(app, deps as never);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
expect(res.json<{ name: string | null }>().name).toBeNull();
});
it('returns 401 for unauthenticated request', async () => {
const deps = createMockDeps();
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerAuthRoutes(app, deps as never);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
});
expect(res.statusCode).toBe(401);
});
it('returns 401 for invalid token', async () => {
const deps = createMockDeps();
deps.authService.findSession.mockResolvedValue(null);
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerAuthRoutes(app, deps as never);
await app.ready();
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: 'Bearer bad-token' },
});
expect(res.statusCode).toBe(401);
});
});