Skip to content

Commit 2292fe5

Browse files
committed
feat(audit): add centralized audit event emission middleware
- Integrates: reusable audit emitter module and plugin in app/server/src/modules/audit/emitter.ts and app/server/src/plugins/audit.ts, plus server/type wiring in app/server/src/index.ts and app/server/src/types/fastify.d.ts. - Security/Behavior: standardizes audit persistence across signup/signin/signout, refresh compromise handling, OAuth linkage/disconnect, MFA enable/disable, and passkey flows with consistent request metadata capture. - Validation: added audit plugin behavior coverage in app/server/test/audit-plugin.test.ts; runtime execution remains pending because local dependency installation is not present in this environment.
1 parent 422fb7d commit 2292fe5

13 files changed

Lines changed: 369 additions & 22 deletions

File tree

app/server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export async function buildServer(): Promise<FastifyInstance> {
2323
await server.register(import('./plugins/rate-limiter'))
2424
await server.register(import('./plugins/database'))
2525
await server.register(import('./plugins/cache'))
26+
await server.register(import('./plugins/audit'))
2627
await server.register(import('./plugins/email'))
2728
await server.register(import('./plugins/email-queue'))
2829
await server.register(import('./plugins/webhook-queue'))
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { FastifyInstance, FastifyRequest } from 'fastify'
2+
3+
export const auditEventCatalog = [
4+
'user.created',
5+
'user.updated',
6+
'user.deleted',
7+
'user.signed_in',
8+
'user.signed_out',
9+
'session.created',
10+
'session.revoked',
11+
'session.compromised',
12+
'oauth.connected',
13+
'oauth.disconnected',
14+
'passkey.registered',
15+
'passkey.removed',
16+
'mfa.enabled',
17+
'mfa.disabled',
18+
'password.changed',
19+
'password.reset',
20+
'email.verified',
21+
] as const
22+
23+
export type AuditEventName = (typeof auditEventCatalog)[number]
24+
25+
export type AuditEventInput = {
26+
projectId: string
27+
event: AuditEventName | (string & {})
28+
userId?: string
29+
metadata?: Record<string, unknown>
30+
request?: FastifyRequest
31+
}
32+
33+
export async function emitAuditEvent(
34+
server: FastifyInstance,
35+
input: AuditEventInput,
36+
): Promise<void> {
37+
await server.dbAdapter.createAuditLog({
38+
projectId: input.projectId,
39+
userId: input.userId,
40+
event: input.event,
41+
ipAddress: input.request?.ip,
42+
userAgent: input.request?.headers['user-agent'] as string | undefined,
43+
metadata: input.metadata ?? {},
44+
})
45+
}

app/server/src/modules/auth/refresh.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,17 @@ async function handleReuseDetection(
7878
): Promise<never> {
7979
await request.server.dbAdapter.revokeSessionFamily(marker.tokenFamily)
8080

81-
await request.server.dbAdapter.createAuditLog({
82-
projectId: marker.projectId,
83-
userId: marker.userId,
84-
event: 'session.compromised',
85-
ipAddress: request.ip,
86-
userAgent: request.headers['user-agent'] as string | undefined,
87-
metadata: {
88-
reason: 'refresh_token_reuse_detected',
89-
},
90-
})
81+
if (typeof request.server.emitAuditEvent === 'function') {
82+
await request.server.emitAuditEvent({
83+
projectId: marker.projectId,
84+
userId: marker.userId,
85+
event: 'session.compromised',
86+
request,
87+
metadata: {
88+
reason: 'refresh_token_reuse_detected',
89+
},
90+
})
91+
}
9192

9293
if (typeof request.server.emitWebhookEvent === 'function') {
9394
try {

app/server/src/modules/auth/signin.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,28 @@ export async function signinHandler(
149149
}
150150
}
151151

152+
if (typeof request.server.emitAuditEvent === 'function') {
153+
await request.server.emitAuditEvent({
154+
projectId,
155+
userId: user.id,
156+
event: 'user.signed_in',
157+
request,
158+
metadata: {
159+
method: 'password',
160+
},
161+
})
162+
163+
await request.server.emitAuditEvent({
164+
projectId,
165+
userId: user.id,
166+
event: 'session.created',
167+
request,
168+
metadata: {
169+
sessionId: session.id,
170+
},
171+
})
172+
}
173+
152174
reply.send({
153175
data: {
154176
user,

app/server/src/modules/auth/signout.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,28 @@ export async function signoutHandler(
6868
request.log.warn({ error }, 'Failed to enqueue signout webhook events')
6969
}
7070
}
71+
72+
if (typeof request.server.emitAuditEvent === 'function') {
73+
await request.server.emitAuditEvent({
74+
projectId: payload.pid,
75+
userId: payload.sub,
76+
event: 'user.signed_out',
77+
request,
78+
metadata: {
79+
sessionId: session.id,
80+
},
81+
})
82+
83+
await request.server.emitAuditEvent({
84+
projectId: payload.pid,
85+
userId: payload.sub,
86+
event: 'session.revoked',
87+
request,
88+
metadata: {
89+
sessionId: session.id,
90+
},
91+
})
92+
}
7193
}
7294

7395
clearRefreshTokenCookie(reply, config.nodeEnv === 'production')

app/server/src/modules/auth/signup.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,28 @@ export async function signupHandler(
123123
}
124124
}
125125

126+
if (typeof request.server.emitAuditEvent === 'function') {
127+
await request.server.emitAuditEvent({
128+
projectId,
129+
userId: user.id,
130+
event: 'user.created',
131+
request,
132+
metadata: {
133+
method: 'password',
134+
},
135+
})
136+
137+
await request.server.emitAuditEvent({
138+
projectId,
139+
userId: user.id,
140+
event: 'session.created',
141+
request,
142+
metadata: {
143+
sessionId: session.id,
144+
},
145+
})
146+
}
147+
126148
reply.code(201).send({
127149
data: {
128150
user,

app/server/src/modules/mfa/handlers.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export async function mfaTotpEnableHandler(
139139
request: FastifyRequest,
140140
reply: FastifyReply,
141141
): Promise<void> {
142-
const { userId } = await getCurrentUser(request)
142+
const { userId, projectId } = await getCurrentUser(request)
143143
const parsed = mfaCodeBodySchema.parse(request.body)
144144

145145
const pendingRaw = await request.server.cache.get(setupCacheKey(userId))
@@ -177,6 +177,18 @@ export async function mfaTotpEnableHandler(
177177
300,
178178
)
179179

180+
if (typeof request.server.emitAuditEvent === 'function') {
181+
await request.server.emitAuditEvent({
182+
projectId,
183+
userId,
184+
event: 'mfa.enabled',
185+
request,
186+
metadata: {
187+
method: 'totp',
188+
},
189+
})
190+
}
191+
180192
reply.send({
181193
data: {
182194
success: true,
@@ -189,7 +201,7 @@ export async function mfaTotpDisableHandler(
189201
request: FastifyRequest,
190202
reply: FastifyReply,
191203
): Promise<void> {
192-
const { userId } = await getCurrentUser(request)
204+
const { userId, projectId } = await getCurrentUser(request)
193205
const parsed = mfaCodeBodySchema.parse(request.body)
194206

195207
const valid = await verifyActiveMfaCode(request, userId, parsed.code)
@@ -202,6 +214,18 @@ export async function mfaTotpDisableHandler(
202214
await request.server.cache.delete(setupCacheKey(userId))
203215
await request.server.cache.delete(backupViewCacheKey(userId))
204216

217+
if (typeof request.server.emitAuditEvent === 'function') {
218+
await request.server.emitAuditEvent({
219+
projectId,
220+
userId,
221+
event: 'mfa.disabled',
222+
request,
223+
metadata: {
224+
method: 'totp',
225+
},
226+
})
227+
}
228+
205229
reply.send({
206230
data: {
207231
success: true,

app/server/src/modules/oauth/disconnect.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,17 @@ export async function oauthDisconnectHandler(
6262
}
6363
}
6464

65-
await request.server.dbAdapter.createAuditLog({
66-
projectId: auth.pid,
67-
userId: auth.sub,
68-
event: 'oauth.disconnected',
69-
ipAddress: request.ip,
70-
userAgent: request.headers['user-agent'] as string | undefined,
71-
metadata: {
72-
provider: providerId,
73-
},
74-
})
65+
if (typeof request.server.emitAuditEvent === 'function') {
66+
await request.server.emitAuditEvent({
67+
projectId: auth.pid,
68+
userId: auth.sub,
69+
event: 'oauth.disconnected',
70+
request,
71+
metadata: {
72+
provider: providerId,
73+
},
74+
})
75+
}
7576

7677
reply.send({
7778
data: {

app/server/src/modules/oauth/handlers.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,40 @@ export async function oauthCallbackHandler(
304304
}
305305
}
306306

307+
if (typeof request.server.emitAuditEvent === 'function') {
308+
if (oauthResult.linkedAccountCreated) {
309+
await request.server.emitAuditEvent({
310+
projectId: state.projectId,
311+
userId: user.id,
312+
event: 'oauth.connected',
313+
request,
314+
metadata: {
315+
provider: providerConfig.id,
316+
},
317+
})
318+
}
319+
320+
await request.server.emitAuditEvent({
321+
projectId: state.projectId,
322+
userId: user.id,
323+
event: 'user.signed_in',
324+
request,
325+
metadata: {
326+
method: `oauth:${providerConfig.id}`,
327+
},
328+
})
329+
330+
await request.server.emitAuditEvent({
331+
projectId: state.projectId,
332+
userId: user.id,
333+
event: 'session.created',
334+
request,
335+
metadata: {
336+
sessionId: session.id,
337+
},
338+
})
339+
}
340+
307341
const location = appendHash(
308342
appendQuery(state.redirectUrl, {
309343
provider: providerConfig.id,

0 commit comments

Comments
 (0)