@checodo/better-auth-audit-log 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE.md +21 -0
- package/README.md +49 -2
- package/dist/client.d.ts +33 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +30 -0
- package/dist/client.js.map +1 -0
- package/dist/error-codes-B9Frh9bu.js +11 -0
- package/dist/error-codes-B9Frh9bu.js.map +1 -0
- package/dist/index-e6JB3Yiu.d.ts +707 -0
- package/dist/index-e6JB3Yiu.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1478 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1478 @@
|
|
|
1
|
+
import { t as AUDIT_LOG_ERROR_CODES } from "./error-codes-B9Frh9bu.js";
|
|
2
|
+
import { mergeSchema } from "better-auth/db";
|
|
3
|
+
import { getCurrentAdapter, queueAfterTransactionHook, runWithAdapter, tryGetCurrentAuthEndpointContext } from "@better-auth/core/context";
|
|
4
|
+
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
5
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
6
|
+
import { getIP } from "@better-auth/core/utils/ip";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
// package.json
|
|
9
|
+
var version = "0.1.0";
|
|
10
|
+
// src/utils/method.ts
|
|
11
|
+
/**
|
|
12
|
+
* How a user signed in or up, from the endpoint that did it:
|
|
13
|
+
* `/sign-in/email` → `email`, `/callback/github` → `github`,
|
|
14
|
+
* `/magic-link/verify` → `magic-link`, `/two-factor/verify-totp` → `two-factor`.
|
|
15
|
+
*/
|
|
16
|
+
function signInMethod(path, params, body) {
|
|
17
|
+
const provider = params?.id ?? params?.providerId;
|
|
18
|
+
if (typeof provider === "string" && provider) return provider;
|
|
19
|
+
if (!path) return null;
|
|
20
|
+
if (path === "/sign-in/social" && typeof body?.provider === "string") return body.provider;
|
|
21
|
+
const [first, second] = path.split("/").filter(Boolean);
|
|
22
|
+
if ((first === "sign-in" || first === "sign-up") && second) return second;
|
|
23
|
+
return first ?? null;
|
|
24
|
+
}
|
|
25
|
+
// src/hooks/database.ts
|
|
26
|
+
/** Endpoints that verify an email address (not OAuth sign-ins, which rewrite the flag). */
|
|
27
|
+
const VERIFY_EMAIL_PATHS = /* @__PURE__ */ new Set(["/verify-email", "/email-otp/verify-email"]);
|
|
28
|
+
/**
|
|
29
|
+
* How long a new user's id is remembered, so the account created with the
|
|
30
|
+
* user (same request, milliseconds later) isn't recorded as a link.
|
|
31
|
+
*/
|
|
32
|
+
const NEW_USER_TTL = 1e4;
|
|
33
|
+
/**
|
|
34
|
+
* The user an update is for, as far as the request tells: the target of
|
|
35
|
+
* the admin plugin's `updateUser`, otherwise the signed-in user (the
|
|
36
|
+
* two-factor plugin changes the user's own setting).
|
|
37
|
+
*/
|
|
38
|
+
function updatedUserId(context) {
|
|
39
|
+
if (context.path === "/admin/update-user") {
|
|
40
|
+
const userId = context.body?.userId;
|
|
41
|
+
return typeof userId === "string" ? userId : null;
|
|
42
|
+
}
|
|
43
|
+
return context.context.session?.user.id ?? null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Events about users and accounts, recorded however they happen (any
|
|
47
|
+
* endpoint, any plugin, server-side calls): sign-ups, deleted users,
|
|
48
|
+
* linked accounts, changed and verified emails, two-factor turned on or off.
|
|
49
|
+
*/
|
|
50
|
+
function createDatabaseHooks(authContext, recorder) {
|
|
51
|
+
const contextFor = (context) => context?.context ?? authContext;
|
|
52
|
+
const headersOf = (context) => context?.request?.headers ?? context?.headers ?? null;
|
|
53
|
+
/** The signed-in user acting, e.g. an admin; `fallback` when nobody is signed in. */
|
|
54
|
+
const actor = (context, fallback) => context?.context.session?.user.id ?? fallback;
|
|
55
|
+
const newUsers = /* @__PURE__ */ new Map();
|
|
56
|
+
const rememberNewUser = (userId) => {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
for (const [id, at] of newUsers) if (now - at > NEW_USER_TTL) newUsers.delete(id);
|
|
59
|
+
newUsers.set(userId, now);
|
|
60
|
+
};
|
|
61
|
+
const wasJustCreated = (userId) => {
|
|
62
|
+
const at = newUsers.get(userId);
|
|
63
|
+
newUsers.delete(userId);
|
|
64
|
+
return at !== void 0 && Date.now() - at <= NEW_USER_TTL;
|
|
65
|
+
};
|
|
66
|
+
const pendingUpdates = /* @__PURE__ */ new WeakMap();
|
|
67
|
+
const record = (context, details) => recorder.record(contextFor(context), details, headersOf(context));
|
|
68
|
+
return {
|
|
69
|
+
user: {
|
|
70
|
+
create: { after: async (user, context) => {
|
|
71
|
+
rememberNewUser(user.id);
|
|
72
|
+
await record(context, {
|
|
73
|
+
type: "sign-up",
|
|
74
|
+
userId: user.id,
|
|
75
|
+
actorUserId: actor(context, user.id),
|
|
76
|
+
metadata: { method: signInMethod(context?.path, context?.params, context?.body) }
|
|
77
|
+
});
|
|
78
|
+
} },
|
|
79
|
+
update: {
|
|
80
|
+
before: async (data, context) => {
|
|
81
|
+
if (!context) return;
|
|
82
|
+
const flags = {
|
|
83
|
+
emailChanged: null,
|
|
84
|
+
emailVerified: false,
|
|
85
|
+
twoFactor: null,
|
|
86
|
+
twoFactorUserId: null
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
if (typeof data.email === "string") {
|
|
90
|
+
const email = data.email.toLowerCase();
|
|
91
|
+
if (!await (await getCurrentAdapter(contextFor(context).adapter)).findOne({
|
|
92
|
+
model: "user",
|
|
93
|
+
where: [{
|
|
94
|
+
field: "email",
|
|
95
|
+
value: email
|
|
96
|
+
}]
|
|
97
|
+
})) flags.emailChanged = email;
|
|
98
|
+
}
|
|
99
|
+
flags.emailVerified = !flags.emailChanged && data.emailVerified === true && VERIFY_EMAIL_PATHS.has(context.path ?? "");
|
|
100
|
+
const twoFactorUserId = typeof data.twoFactorEnabled === "boolean" ? updatedUserId(context) : null;
|
|
101
|
+
if (twoFactorUserId) {
|
|
102
|
+
const stored = await (await getCurrentAdapter(contextFor(context).adapter)).findOne({
|
|
103
|
+
model: "user",
|
|
104
|
+
where: [{
|
|
105
|
+
field: "id",
|
|
106
|
+
value: twoFactorUserId
|
|
107
|
+
}]
|
|
108
|
+
});
|
|
109
|
+
if (stored && stored.twoFactorEnabled === true !== data.twoFactorEnabled) {
|
|
110
|
+
flags.twoFactor = data.twoFactorEnabled;
|
|
111
|
+
flags.twoFactorUserId = twoFactorUserId;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
contextFor(context).logger.error("[audit-log] failed to inspect a user update", error);
|
|
116
|
+
}
|
|
117
|
+
const queue = pendingUpdates.get(context) ?? [];
|
|
118
|
+
queue.push(flags);
|
|
119
|
+
pendingUpdates.set(context, queue);
|
|
120
|
+
},
|
|
121
|
+
after: async (user, context) => {
|
|
122
|
+
if (!context || !user) return;
|
|
123
|
+
const flags = pendingUpdates.get(context)?.shift();
|
|
124
|
+
if (!flags) return;
|
|
125
|
+
if (flags.emailChanged && user.email?.toLowerCase() === flags.emailChanged) await record(context, {
|
|
126
|
+
type: "email-changed",
|
|
127
|
+
userId: user.id,
|
|
128
|
+
actorUserId: actor(context, user.id),
|
|
129
|
+
metadata: { email: flags.emailChanged }
|
|
130
|
+
});
|
|
131
|
+
else if (flags.emailVerified) await record(context, {
|
|
132
|
+
type: "email-verified",
|
|
133
|
+
userId: user.id,
|
|
134
|
+
actorUserId: user.id
|
|
135
|
+
});
|
|
136
|
+
const twoFactorEnabled = user.twoFactorEnabled;
|
|
137
|
+
if (flags.twoFactor !== null && user.id === flags.twoFactorUserId && twoFactorEnabled === flags.twoFactor) await record(context, {
|
|
138
|
+
type: flags.twoFactor ? "two-factor-enabled" : "two-factor-disabled",
|
|
139
|
+
userId: user.id,
|
|
140
|
+
actorUserId: actor(context, user.id)
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
delete: { after: async (user, context) => {
|
|
145
|
+
await record(context, {
|
|
146
|
+
type: "user-deleted",
|
|
147
|
+
userId: user.id,
|
|
148
|
+
actorUserId: actor(context, user.id)
|
|
149
|
+
});
|
|
150
|
+
} }
|
|
151
|
+
},
|
|
152
|
+
account: { create: { after: async (account, context) => {
|
|
153
|
+
if (wasJustCreated(account.userId)) return;
|
|
154
|
+
await record(context, {
|
|
155
|
+
type: "account-linked",
|
|
156
|
+
userId: account.userId,
|
|
157
|
+
actorUserId: actor(context, account.userId),
|
|
158
|
+
metadata: { providerId: account.providerId }
|
|
159
|
+
});
|
|
160
|
+
} } }
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// src/hooks/paths.ts
|
|
164
|
+
/** Where the before-hook leaves what it looked up for the after-hook. */
|
|
165
|
+
const SUBJECT_KEY = "auditLogSubjectUserId";
|
|
166
|
+
/** Endpoints whose failures are failed sign-ins. Only these: they check a credential. */
|
|
167
|
+
const SIGN_IN_PATHS = /* @__PURE__ */ new Set([
|
|
168
|
+
"/sign-in/email",
|
|
169
|
+
"/sign-in/username",
|
|
170
|
+
"/sign-in/phone-number",
|
|
171
|
+
"/sign-in/email-otp",
|
|
172
|
+
"/two-factor/verify-totp",
|
|
173
|
+
"/two-factor/verify-otp",
|
|
174
|
+
"/two-factor/verify-backup-code"
|
|
175
|
+
]);
|
|
176
|
+
/**
|
|
177
|
+
* Endpoints that set a new session cookie for a user who is already signed
|
|
178
|
+
* in (rotation, impersonation): not sign-ins. Most of them also load the
|
|
179
|
+
* session, which rules them out anyway; this list covers the rest.
|
|
180
|
+
*/
|
|
181
|
+
const NOT_SIGN_IN_PATHS = /* @__PURE__ */ new Set([
|
|
182
|
+
"/change-password",
|
|
183
|
+
"/update-user",
|
|
184
|
+
"/update-session",
|
|
185
|
+
"/change-email",
|
|
186
|
+
"/two-factor/enable",
|
|
187
|
+
"/two-factor/disable",
|
|
188
|
+
"/admin/impersonate-user",
|
|
189
|
+
"/admin/stop-impersonating",
|
|
190
|
+
"/multi-session/set-active"
|
|
191
|
+
]);
|
|
192
|
+
const target = (body) => body.userId;
|
|
193
|
+
const lookedUp = (_body, _returned, userId) => userId;
|
|
194
|
+
/** A field of the endpoint's result, if it succeeded (`returned` is an error otherwise). */
|
|
195
|
+
function field(returned, ...path) {
|
|
196
|
+
let value = isAPIError(returned) ? void 0 : returned;
|
|
197
|
+
for (const key of path) value = value && typeof value === "object" ? value[key] : void 0;
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
/** Endpoints whose subject is the user of a password reset, looked up before the reset. */
|
|
201
|
+
const PASSWORD_RESET_PATHS = /* @__PURE__ */ new Set([
|
|
202
|
+
"/reset-password",
|
|
203
|
+
"/email-otp/reset-password",
|
|
204
|
+
"/phone-number/reset-password"
|
|
205
|
+
]);
|
|
206
|
+
const passwordReset = {
|
|
207
|
+
type: "password-reset",
|
|
208
|
+
subject: lookedUp
|
|
209
|
+
};
|
|
210
|
+
/** Endpoints recorded by path. Admin actions are recorded on success and on failure. */
|
|
211
|
+
const PATH_EVENTS = {
|
|
212
|
+
"/sign-out": { type: "sign-out" },
|
|
213
|
+
"/revoke-session": {
|
|
214
|
+
type: "session-revoked",
|
|
215
|
+
metadata: () => ({ scope: "session" })
|
|
216
|
+
},
|
|
217
|
+
"/revoke-sessions": {
|
|
218
|
+
type: "session-revoked",
|
|
219
|
+
metadata: () => ({ scope: "all" })
|
|
220
|
+
},
|
|
221
|
+
"/revoke-other-sessions": {
|
|
222
|
+
type: "session-revoked",
|
|
223
|
+
metadata: () => ({ scope: "others" })
|
|
224
|
+
},
|
|
225
|
+
"/change-password": {
|
|
226
|
+
type: "password-changed",
|
|
227
|
+
metadata: (body) => ({ revokeOtherSessions: body.revokeOtherSessions === true })
|
|
228
|
+
},
|
|
229
|
+
"/reset-password": passwordReset,
|
|
230
|
+
"/email-otp/reset-password": passwordReset,
|
|
231
|
+
"/phone-number/reset-password": passwordReset,
|
|
232
|
+
"/unlink-account": {
|
|
233
|
+
type: "account-unlinked",
|
|
234
|
+
metadata: (body) => ({ providerId: body.providerId })
|
|
235
|
+
},
|
|
236
|
+
"/admin/create-user": {
|
|
237
|
+
type: "admin.create-user",
|
|
238
|
+
subject: (_body, returned) => returned?.user?.id,
|
|
239
|
+
metadata: (body) => ({ role: body.role ?? null })
|
|
240
|
+
},
|
|
241
|
+
"/admin/update-user": {
|
|
242
|
+
type: "admin.update-user",
|
|
243
|
+
subject: target,
|
|
244
|
+
metadata: (body) => ({ fields: body.data && typeof body.data === "object" ? Object.keys(body.data) : [] })
|
|
245
|
+
},
|
|
246
|
+
"/admin/set-role": {
|
|
247
|
+
type: "admin.set-role",
|
|
248
|
+
subject: target,
|
|
249
|
+
metadata: (body) => ({ role: body.role })
|
|
250
|
+
},
|
|
251
|
+
"/admin/set-user-password": {
|
|
252
|
+
type: "admin.set-user-password",
|
|
253
|
+
subject: target
|
|
254
|
+
},
|
|
255
|
+
"/admin/ban-user": {
|
|
256
|
+
type: "admin.ban-user",
|
|
257
|
+
subject: target,
|
|
258
|
+
metadata: (body) => ({
|
|
259
|
+
reason: body.banReason ?? null,
|
|
260
|
+
expiresIn: body.banExpiresIn ?? null
|
|
261
|
+
})
|
|
262
|
+
},
|
|
263
|
+
"/admin/unban-user": {
|
|
264
|
+
type: "admin.unban-user",
|
|
265
|
+
subject: target
|
|
266
|
+
},
|
|
267
|
+
"/admin/impersonate-user": {
|
|
268
|
+
type: "admin.impersonate-user",
|
|
269
|
+
subject: target
|
|
270
|
+
},
|
|
271
|
+
"/admin/stop-impersonating": { type: "admin.stop-impersonating" },
|
|
272
|
+
"/admin/revoke-user-session": {
|
|
273
|
+
type: "admin.revoke-user-session",
|
|
274
|
+
subject: lookedUp
|
|
275
|
+
},
|
|
276
|
+
"/admin/revoke-user-sessions": {
|
|
277
|
+
type: "admin.revoke-user-sessions",
|
|
278
|
+
subject: target
|
|
279
|
+
},
|
|
280
|
+
"/admin/remove-user": {
|
|
281
|
+
type: "admin.remove-user",
|
|
282
|
+
subject: target
|
|
283
|
+
},
|
|
284
|
+
"/organization/create": {
|
|
285
|
+
type: "organization.created",
|
|
286
|
+
organization: (_body, returned) => field(returned, "id"),
|
|
287
|
+
metadata: (body) => ({
|
|
288
|
+
name: body.name ?? null,
|
|
289
|
+
slug: body.slug ?? null
|
|
290
|
+
})
|
|
291
|
+
},
|
|
292
|
+
"/organization/update": {
|
|
293
|
+
type: "organization.updated",
|
|
294
|
+
organization: (body, returned) => field(returned, "id") ?? body.organizationId,
|
|
295
|
+
metadata: (body) => ({ fields: body.data && typeof body.data === "object" ? Object.keys(body.data) : [] })
|
|
296
|
+
},
|
|
297
|
+
"/organization/delete": {
|
|
298
|
+
type: "organization.deleted",
|
|
299
|
+
organization: (body) => body.organizationId
|
|
300
|
+
},
|
|
301
|
+
"/organization/invite-member": {
|
|
302
|
+
type: "organization.invitation-created",
|
|
303
|
+
organization: (body, returned) => field(returned, "organizationId") ?? body.organizationId,
|
|
304
|
+
metadata: (body, returned) => ({
|
|
305
|
+
invitationId: field(returned, "id") ?? null,
|
|
306
|
+
email: typeof body.email === "string" ? body.email.toLowerCase() : null,
|
|
307
|
+
role: body.role ?? null
|
|
308
|
+
})
|
|
309
|
+
},
|
|
310
|
+
"/organization/accept-invitation": {
|
|
311
|
+
type: "organization.invitation-accepted",
|
|
312
|
+
organization: (_body, returned) => field(returned, "invitation", "organizationId"),
|
|
313
|
+
metadata: (body, returned) => ({
|
|
314
|
+
invitationId: body.invitationId ?? null,
|
|
315
|
+
memberId: field(returned, "member", "id") ?? null,
|
|
316
|
+
role: field(returned, "member", "role") ?? null
|
|
317
|
+
})
|
|
318
|
+
},
|
|
319
|
+
"/organization/reject-invitation": {
|
|
320
|
+
type: "organization.invitation-rejected",
|
|
321
|
+
organization: (_body, returned) => field(returned, "invitation", "organizationId"),
|
|
322
|
+
metadata: (body) => ({ invitationId: body.invitationId ?? null })
|
|
323
|
+
},
|
|
324
|
+
"/organization/cancel-invitation": {
|
|
325
|
+
type: "organization.invitation-canceled",
|
|
326
|
+
organization: (_body, returned) => field(returned, "organizationId"),
|
|
327
|
+
metadata: (body, returned) => ({
|
|
328
|
+
invitationId: body.invitationId ?? null,
|
|
329
|
+
email: field(returned, "email") ?? null
|
|
330
|
+
})
|
|
331
|
+
},
|
|
332
|
+
"/organization/remove-member": {
|
|
333
|
+
type: "organization.member-removed",
|
|
334
|
+
subject: (_body, returned) => field(returned, "member", "userId"),
|
|
335
|
+
organization: (body, returned) => field(returned, "member", "organizationId") ?? body.organizationId,
|
|
336
|
+
metadata: (_body, returned) => ({ memberId: field(returned, "member", "id") ?? null })
|
|
337
|
+
},
|
|
338
|
+
"/organization/update-member-role": {
|
|
339
|
+
type: "organization.member-role-changed",
|
|
340
|
+
subject: (_body, returned) => field(returned, "userId"),
|
|
341
|
+
organization: (body, returned) => field(returned, "organizationId") ?? body.organizationId,
|
|
342
|
+
metadata: (body) => ({
|
|
343
|
+
memberId: body.memberId ?? null,
|
|
344
|
+
role: body.role ?? null
|
|
345
|
+
})
|
|
346
|
+
},
|
|
347
|
+
"/organization/leave": {
|
|
348
|
+
type: "organization.member-left",
|
|
349
|
+
organization: (body) => body.organizationId
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const asString = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
353
|
+
/**
|
|
354
|
+
* Looks up, before the endpoint runs, what is gone afterwards: the
|
|
355
|
+
* session (sign-out, revoke), the user of a password reset (by token, or by
|
|
356
|
+
* the email or phone number of an OTP reset), or of a session an admin
|
|
357
|
+
* revokes.
|
|
358
|
+
*/
|
|
359
|
+
async function lookUpSubject(ctx) {
|
|
360
|
+
const body = ctx.body ?? {};
|
|
361
|
+
if (ctx.path === "/reset-password") {
|
|
362
|
+
const token = asString(body.token) ?? asString(ctx.query?.token);
|
|
363
|
+
if (!token) return void 0;
|
|
364
|
+
return (await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`))?.value;
|
|
365
|
+
}
|
|
366
|
+
if (ctx.path === "/email-otp/reset-password") {
|
|
367
|
+
const email = asString(body.email)?.trim().toLowerCase();
|
|
368
|
+
if (!email) return void 0;
|
|
369
|
+
return (await ctx.context.internalAdapter.findUserByEmail(email))?.user.id;
|
|
370
|
+
}
|
|
371
|
+
if (ctx.path === "/phone-number/reset-password") {
|
|
372
|
+
const phone = asString(body.phoneNumber)?.trim();
|
|
373
|
+
if (!phone) return void 0;
|
|
374
|
+
return (await ctx.context.adapter.findOne({
|
|
375
|
+
model: "user",
|
|
376
|
+
where: [{
|
|
377
|
+
field: "phoneNumber",
|
|
378
|
+
value: phone
|
|
379
|
+
}]
|
|
380
|
+
}).catch(() => null))?.id;
|
|
381
|
+
}
|
|
382
|
+
if (ctx.path === "/admin/revoke-user-session") {
|
|
383
|
+
const token = asString(body.sessionToken);
|
|
384
|
+
if (!token) return void 0;
|
|
385
|
+
return (await ctx.context.internalAdapter.findSession(token))?.user.id;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
389
|
+
const PHONE = /^\+?[0-9 ()-]{5,20}$/;
|
|
390
|
+
/**
|
|
391
|
+
* Who a failed sign-in was for. People type their password into the
|
|
392
|
+
* email or username field, so the input is only kept when it is clearly
|
|
393
|
+
* an identifier: a well-formed email or phone number, or the username of
|
|
394
|
+
* an existing user.
|
|
395
|
+
*/
|
|
396
|
+
async function failedSignInSubject(ctx, body) {
|
|
397
|
+
const email = asString(body.email)?.trim().toLowerCase();
|
|
398
|
+
if (email) {
|
|
399
|
+
if (!EMAIL.test(email) || email.length > 255) return {
|
|
400
|
+
userId: null,
|
|
401
|
+
identifier: null
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
userId: (await ctx.context.internalAdapter.findUserByEmail(email))?.user.id ?? null,
|
|
405
|
+
identifier: email
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const username = asString(body.username)?.trim().toLowerCase();
|
|
409
|
+
if (username) {
|
|
410
|
+
const user = await ctx.context.adapter.findOne({
|
|
411
|
+
model: "user",
|
|
412
|
+
where: [{
|
|
413
|
+
field: "username",
|
|
414
|
+
value: username
|
|
415
|
+
}]
|
|
416
|
+
}).catch(() => null);
|
|
417
|
+
return user ? {
|
|
418
|
+
userId: user.id,
|
|
419
|
+
identifier: username
|
|
420
|
+
} : {
|
|
421
|
+
userId: null,
|
|
422
|
+
identifier: null
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
const phone = asString(body.phoneNumber)?.trim();
|
|
426
|
+
if (phone && PHONE.test(phone)) return {
|
|
427
|
+
userId: (await ctx.context.adapter.findOne({
|
|
428
|
+
model: "user",
|
|
429
|
+
where: [{
|
|
430
|
+
field: "phoneNumber",
|
|
431
|
+
value: phone
|
|
432
|
+
}]
|
|
433
|
+
}).catch(() => null))?.id ?? null,
|
|
434
|
+
identifier: phone
|
|
435
|
+
};
|
|
436
|
+
return {
|
|
437
|
+
userId: null,
|
|
438
|
+
identifier: null
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function failureOf(returned) {
|
|
442
|
+
if (!isAPIError(returned) || returned.statusCode < 400) return {
|
|
443
|
+
failed: false,
|
|
444
|
+
errorCode: null
|
|
445
|
+
};
|
|
446
|
+
const code = returned.body?.code;
|
|
447
|
+
return {
|
|
448
|
+
failed: true,
|
|
449
|
+
errorCode: typeof code === "string" ? code : returned.status.toString()
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function sessionDetails(session) {
|
|
453
|
+
if (!session) return {};
|
|
454
|
+
const { id, activeOrganizationId } = session.session;
|
|
455
|
+
return {
|
|
456
|
+
sessionId: id,
|
|
457
|
+
organizationId: asString(activeOrganizationId)
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
function createPathHooks(options, recorder) {
|
|
461
|
+
const eventFor = (path) => {
|
|
462
|
+
if (!path) return void 0;
|
|
463
|
+
if (Object.hasOwn(options.paths, path)) return { type: options.paths[path] };
|
|
464
|
+
return Object.hasOwn(PATH_EVENTS, path) ? PATH_EVENTS[path] : void 0;
|
|
465
|
+
};
|
|
466
|
+
return {
|
|
467
|
+
before: [{
|
|
468
|
+
matcher: (context) => {
|
|
469
|
+
const event = eventFor(context.path);
|
|
470
|
+
return !!event && recorder.isRecorded(event.type);
|
|
471
|
+
},
|
|
472
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
473
|
+
try {
|
|
474
|
+
await getSessionFromCtx(ctx, { disableRefresh: true });
|
|
475
|
+
const subject = await lookUpSubject(ctx);
|
|
476
|
+
if (subject) ctx.context[SUBJECT_KEY] = subject;
|
|
477
|
+
} catch (error) {
|
|
478
|
+
ctx.context.logger.error("[audit-log] failed to prepare an event", error);
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
}],
|
|
482
|
+
after: [{
|
|
483
|
+
matcher: (context) => !!context.path,
|
|
484
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
485
|
+
try {
|
|
486
|
+
await afterEndpoint(ctx);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
ctx.context.logger.error("[audit-log] failed to record an event", error);
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
}]
|
|
492
|
+
};
|
|
493
|
+
async function afterEndpoint(ctx) {
|
|
494
|
+
const { path } = ctx;
|
|
495
|
+
const body = ctx.body ?? {};
|
|
496
|
+
const headers = ctx.request?.headers ?? ctx.headers ?? null;
|
|
497
|
+
const { failed, errorCode } = failureOf(ctx.context.returned);
|
|
498
|
+
const session = ctx.context.session;
|
|
499
|
+
const created = ctx.context.newSession;
|
|
500
|
+
if (!failed && created && !session && !NOT_SIGN_IN_PATHS.has(path) && !created.session.impersonatedBy) await recorder.record(ctx.context, {
|
|
501
|
+
type: "sign-in",
|
|
502
|
+
userId: created.user.id,
|
|
503
|
+
actorUserId: created.user.id,
|
|
504
|
+
...sessionDetails(created),
|
|
505
|
+
metadata: { method: signInMethod(path, ctx.params, body) }
|
|
506
|
+
}, headers);
|
|
507
|
+
if (failed && SIGN_IN_PATHS.has(path)) {
|
|
508
|
+
const { userId, identifier } = await failedSignInSubject(ctx, body);
|
|
509
|
+
await recorder.record(ctx.context, {
|
|
510
|
+
type: "sign-in",
|
|
511
|
+
status: "failure",
|
|
512
|
+
errorCode,
|
|
513
|
+
userId,
|
|
514
|
+
metadata: {
|
|
515
|
+
method: signInMethod(path),
|
|
516
|
+
...identifier ? { identifier } : {}
|
|
517
|
+
}
|
|
518
|
+
}, headers);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const event = eventFor(path);
|
|
522
|
+
if (!event) return;
|
|
523
|
+
const subject = ctx.context[SUBJECT_KEY];
|
|
524
|
+
let actorUserId = session?.user.id ?? null;
|
|
525
|
+
let userId = event.subject ? asString(event.subject(body, ctx.context.returned, subject)) : actorUserId;
|
|
526
|
+
if (path === "/admin/stop-impersonating" && session) {
|
|
527
|
+
actorUserId = asString(session.session.impersonatedBy) ?? session.user.id;
|
|
528
|
+
userId = session.user.id;
|
|
529
|
+
}
|
|
530
|
+
if (PASSWORD_RESET_PATHS.has(path)) actorUserId = userId;
|
|
531
|
+
if (!userId && !actorUserId) return;
|
|
532
|
+
const details = sessionDetails(session);
|
|
533
|
+
const organizationId = event.organization ? asString(event.organization(body, ctx.context.returned)) : null;
|
|
534
|
+
await recorder.record(ctx.context, {
|
|
535
|
+
type: event.type,
|
|
536
|
+
status: failed ? "failure" : "success",
|
|
537
|
+
errorCode,
|
|
538
|
+
userId,
|
|
539
|
+
actorUserId,
|
|
540
|
+
...details,
|
|
541
|
+
organizationId: organizationId ?? details.organizationId ?? null,
|
|
542
|
+
metadata: event.metadata ? event.metadata(body, ctx.context.returned) : null
|
|
543
|
+
}, headers);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// ../plugin-utils/src/admin.ts
|
|
547
|
+
/** Applies the defaults of `AdminOptions`. */
|
|
548
|
+
function resolveAdminOptions(options) {
|
|
549
|
+
return {
|
|
550
|
+
adminRoles: options.adminRoles ?? ["admin"],
|
|
551
|
+
adminUserIds: options.adminUserIds ?? []
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Whether `user` is listed in `adminUserIds`, or has one of `adminRoles`
|
|
556
|
+
* in the admin plugin's `role` field, which holds several roles
|
|
557
|
+
* comma-separated (`"user, admin"`).
|
|
558
|
+
*/
|
|
559
|
+
function isAdmin(user, options) {
|
|
560
|
+
if (options.adminUserIds.includes(user.id)) return true;
|
|
561
|
+
if (typeof user.role !== "string") return false;
|
|
562
|
+
return user.role.split(",").some((role) => options.adminRoles.includes(role.trim()));
|
|
563
|
+
}
|
|
564
|
+
// ../plugin-utils/src/options.ts
|
|
565
|
+
/**
|
|
566
|
+
* Returns a function that throws an option error prefixed with the plugin
|
|
567
|
+
* id, `[referral] "codeLength" must be ...`, for validating options at
|
|
568
|
+
* plugin creation.
|
|
569
|
+
*/
|
|
570
|
+
function createFail(pluginId) {
|
|
571
|
+
return (message) => {
|
|
572
|
+
throw new Error(`[${pluginId}] ${message}`);
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
const SEVERITIES = [
|
|
576
|
+
"low",
|
|
577
|
+
"medium",
|
|
578
|
+
"high",
|
|
579
|
+
"critical"
|
|
580
|
+
];
|
|
581
|
+
const fail = createFail("audit-log");
|
|
582
|
+
function isValidEventType(type) {
|
|
583
|
+
return type.length <= 64 && /^[a-z0-9][a-z0-9._-]*$/i.test(type);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Compiles `events` patterns into a matcher: exact types, or `group.*`
|
|
587
|
+
* for every type starting with `group.`.
|
|
588
|
+
*/
|
|
589
|
+
function compileEventFilter(patterns) {
|
|
590
|
+
const exact = /* @__PURE__ */ new Set();
|
|
591
|
+
const prefixes = [];
|
|
592
|
+
for (const pattern of patterns) if (pattern.endsWith(".*") && isValidEventType(pattern.slice(0, -2))) prefixes.push(pattern.slice(0, -1));
|
|
593
|
+
else if (isValidEventType(pattern)) exact.add(pattern);
|
|
594
|
+
else fail(`Invalid event pattern ${JSON.stringify(pattern)} in "events".`);
|
|
595
|
+
return (type) => exact.has(type) || prefixes.some((prefix) => type.startsWith(prefix));
|
|
596
|
+
}
|
|
597
|
+
/** Resolves defaults and validates the options once, at plugin creation. */
|
|
598
|
+
function resolveOptions(options) {
|
|
599
|
+
const paths = { ...options.paths };
|
|
600
|
+
for (const [path, type] of Object.entries(paths)) {
|
|
601
|
+
if (!path.startsWith("/")) fail(`Path ${JSON.stringify(path)} in "paths" must start with "/".`);
|
|
602
|
+
if (!isValidEventType(type)) fail(`Invalid event type ${JSON.stringify(type)} in "paths": use letters, digits, ".", "-", and "_", up to 64 characters.`);
|
|
603
|
+
}
|
|
604
|
+
const severity = { ...options.severity };
|
|
605
|
+
for (const [type, value] of Object.entries(severity)) if (!SEVERITIES.includes(value)) fail(`Invalid severity ${JSON.stringify(value)} for "${type}": use ${SEVERITIES.join(", ")}.`);
|
|
606
|
+
let retention;
|
|
607
|
+
if (options.retention) {
|
|
608
|
+
const { days, sweepInterval = 3600 } = options.retention;
|
|
609
|
+
if (!(typeof days === "number" && days > 0 && Number.isFinite(days))) fail(`"retention.days" must be a positive number, got ${days}.`);
|
|
610
|
+
if (!(typeof sweepInterval === "number" && sweepInterval > 0)) fail(`"retention.sweepInterval" must be a positive number of seconds, got ${sweepInterval}.`);
|
|
611
|
+
retention = {
|
|
612
|
+
days,
|
|
613
|
+
sweepInterval
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
let hashChain = null;
|
|
617
|
+
if (options.hashChain) {
|
|
618
|
+
if (options.storage) fail("\"hashChain\" needs the default database storage; remove \"storage\" or \"hashChain\".");
|
|
619
|
+
const secret = typeof options.hashChain === "object" ? options.hashChain.secret : void 0;
|
|
620
|
+
if (secret !== void 0 && secret.length < 32) fail("\"hashChain.secret\" must be at least 32 characters.");
|
|
621
|
+
hashChain = { secret };
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
events: options.events ? compileEventFilter(options.events) : null,
|
|
625
|
+
paths,
|
|
626
|
+
severity,
|
|
627
|
+
captureIp: options.captureIp ?? true,
|
|
628
|
+
captureUserAgent: options.captureUserAgent ?? true,
|
|
629
|
+
beforeLog: options.beforeLog,
|
|
630
|
+
onEvent: options.onEvent,
|
|
631
|
+
storage: options.storage,
|
|
632
|
+
retention,
|
|
633
|
+
hashChain,
|
|
634
|
+
...resolveAdminOptions(options)
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
// src/record-event.ts
|
|
638
|
+
/**
|
|
639
|
+
* Records an event for another plugin, through
|
|
640
|
+
* `ctx.context.getPlugin("audit-log")?.recordEvent(...)`, the way official
|
|
641
|
+
* plugins reach each other (`anonymous` calls `twoFactor` like this).
|
|
642
|
+
*
|
|
643
|
+
* Fills in what the request knows: the signed-in user as actor (unless
|
|
644
|
+
* `actorUserId` is given), the session, its active organization, the IP
|
|
645
|
+
* address, and the user agent. Never throws.
|
|
646
|
+
*
|
|
647
|
+
* Inside a database transaction (a database hook while a user is created),
|
|
648
|
+
* the event is written outside it once the transaction ends: an event
|
|
649
|
+
* about something that was refused, like a blocked sign-up, must survive
|
|
650
|
+
* the rollback.
|
|
651
|
+
*/
|
|
652
|
+
function createRecordEvent(recorder) {
|
|
653
|
+
return async (details, endpoint = tryGetCurrentAuthEndpointContext()) => {
|
|
654
|
+
if (!endpoint || !recorder.isRecorded(details.type)) return;
|
|
655
|
+
const ctx = endpoint.context;
|
|
656
|
+
const session = ctx.session;
|
|
657
|
+
const event = {
|
|
658
|
+
actorUserId: session?.user.id ?? null,
|
|
659
|
+
sessionId: session?.session.id ?? null,
|
|
660
|
+
organizationId: (session?.session)?.activeOrganizationId ?? null,
|
|
661
|
+
...details
|
|
662
|
+
};
|
|
663
|
+
const headers = endpoint.request?.headers ?? endpoint.headers ?? null;
|
|
664
|
+
try {
|
|
665
|
+
if (await getCurrentAdapter(ctx.adapter) === ctx.adapter) {
|
|
666
|
+
await recorder.record(ctx, event, headers);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
ctx.runInBackground(runWithAdapter(ctx.adapter, () => recorder.record(ctx, event, headers)));
|
|
670
|
+
} catch (error) {
|
|
671
|
+
ctx.logger.error(`[audit-log] failed to record a "${details.type}" event`, error);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
// src/chain.ts
|
|
676
|
+
/** The `previousHash` of the first link. */
|
|
677
|
+
const GENESIS_HASH = "0".repeat(64);
|
|
678
|
+
/** How often a link is retried when another writer took the sequence number. */
|
|
679
|
+
const MAX_APPEND_ATTEMPTS = 20;
|
|
680
|
+
/** Links read per query while verifying. */
|
|
681
|
+
const VERIFY_PAGE_SIZE = 500;
|
|
682
|
+
const keys = /* @__PURE__ */ new Map();
|
|
683
|
+
function hmacKey(secret) {
|
|
684
|
+
let key = keys.get(secret);
|
|
685
|
+
if (!key) {
|
|
686
|
+
key = crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
687
|
+
name: "HMAC",
|
|
688
|
+
hash: "SHA-256"
|
|
689
|
+
}, false, ["sign"]);
|
|
690
|
+
keys.set(secret, key);
|
|
691
|
+
}
|
|
692
|
+
return key;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Everything the hash covers, in a fixed order. `createdAt` is in whole
|
|
696
|
+
* seconds, because some databases store dates with less precision than
|
|
697
|
+
* JavaScript, and the hash must match what is read back.
|
|
698
|
+
*/
|
|
699
|
+
function canonical(event, sequence, previousHash) {
|
|
700
|
+
return JSON.stringify([
|
|
701
|
+
1,
|
|
702
|
+
sequence,
|
|
703
|
+
previousHash,
|
|
704
|
+
event.id,
|
|
705
|
+
event.type,
|
|
706
|
+
event.status,
|
|
707
|
+
event.severity,
|
|
708
|
+
event.userId ?? null,
|
|
709
|
+
event.actorUserId ?? null,
|
|
710
|
+
event.sessionId ?? null,
|
|
711
|
+
event.organizationId ?? null,
|
|
712
|
+
event.ipAddress ?? null,
|
|
713
|
+
event.userAgent ?? null,
|
|
714
|
+
event.errorCode ?? null,
|
|
715
|
+
event.metadata ? JSON.stringify(event.metadata) : null,
|
|
716
|
+
Math.floor(new Date(event.createdAt).getTime() / 1e3)
|
|
717
|
+
]);
|
|
718
|
+
}
|
|
719
|
+
async function linkHash(secret, event, sequence, previousHash) {
|
|
720
|
+
const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(canonical(event, sequence, previousHash)));
|
|
721
|
+
return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
722
|
+
}
|
|
723
|
+
async function latestLink(adapter) {
|
|
724
|
+
const [link] = await adapter.findMany({
|
|
725
|
+
model: "auditChain",
|
|
726
|
+
limit: 1,
|
|
727
|
+
sortBy: {
|
|
728
|
+
field: "sequence",
|
|
729
|
+
direction: "desc"
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
return link ?? null;
|
|
733
|
+
}
|
|
734
|
+
function createChain(secretOption) {
|
|
735
|
+
const secretFor = (ctx) => secretOption ?? ctx.secret;
|
|
736
|
+
/**
|
|
737
|
+
* Adds a link for `event`. Concurrent writers compete for the next
|
|
738
|
+
* sequence number through its unique index; the loser reads the new
|
|
739
|
+
* latest link and tries again.
|
|
740
|
+
*/
|
|
741
|
+
async function append(ctx, event) {
|
|
742
|
+
const adapter = ctx.adapter;
|
|
743
|
+
for (let attempt = 0; attempt < MAX_APPEND_ATTEMPTS; attempt++) {
|
|
744
|
+
const previous = await latestLink(adapter);
|
|
745
|
+
const sequence = (previous?.sequence ?? 0) + 1;
|
|
746
|
+
const previousHash = previous?.hash ?? GENESIS_HASH;
|
|
747
|
+
const hash = await linkHash(secretFor(ctx), event, sequence, previousHash);
|
|
748
|
+
try {
|
|
749
|
+
await adapter.create({
|
|
750
|
+
model: "auditChain",
|
|
751
|
+
data: {
|
|
752
|
+
sequence,
|
|
753
|
+
eventId: event.id,
|
|
754
|
+
previousHash,
|
|
755
|
+
hash,
|
|
756
|
+
createdAt: event.createdAt
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
return;
|
|
760
|
+
} catch (error) {
|
|
761
|
+
if (!await adapter.findOne({
|
|
762
|
+
model: "auditChain",
|
|
763
|
+
where: [{
|
|
764
|
+
field: "sequence",
|
|
765
|
+
value: sequence
|
|
766
|
+
}]
|
|
767
|
+
})) throw error;
|
|
768
|
+
await new Promise((resolve) => setTimeout(resolve, Math.random() * 5 * (attempt + 1)));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
throw new Error(`[audit-log] could not chain event ${event.id}: too many concurrent writers`);
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* `append`, but never inside a database transaction: a failed write
|
|
775
|
+
* there (a taken sequence number) would abort the transaction on some
|
|
776
|
+
* databases, and with it the sign-up being recorded. Inside one, the
|
|
777
|
+
* link is added after the commit, and dropped with a rollback like its
|
|
778
|
+
* event. Errors are logged: the event is stored, and verification
|
|
779
|
+
* reports it as unchained.
|
|
780
|
+
*/
|
|
781
|
+
async function link(ctx, event) {
|
|
782
|
+
const run = async () => {
|
|
783
|
+
try {
|
|
784
|
+
await append(ctx, event);
|
|
785
|
+
} catch (error) {
|
|
786
|
+
ctx.logger.error("[audit-log] failed to chain an event", error);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
if (await getCurrentAdapter(ctx.adapter) === ctx.adapter) await run();
|
|
790
|
+
else await queueAfterTransactionHook(run);
|
|
791
|
+
}
|
|
792
|
+
/** Deletes links of events created before `date` (retention). */
|
|
793
|
+
async function deleteOlderThan(ctx, date) {
|
|
794
|
+
return ctx.adapter.deleteMany({
|
|
795
|
+
model: "auditChain",
|
|
796
|
+
where: [{
|
|
797
|
+
field: "createdAt",
|
|
798
|
+
value: date,
|
|
799
|
+
operator: "lt"
|
|
800
|
+
}]
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
/** Re-computes every link, oldest first, against the stored events. */
|
|
804
|
+
async function verify(ctx, readEvents) {
|
|
805
|
+
const { adapter } = ctx;
|
|
806
|
+
const secret = secretFor(ctx);
|
|
807
|
+
const problems = [];
|
|
808
|
+
const report = (link, problem) => {
|
|
809
|
+
if (problems.length < 100) problems.push({
|
|
810
|
+
sequence: link.sequence,
|
|
811
|
+
eventId: link.eventId,
|
|
812
|
+
problem
|
|
813
|
+
});
|
|
814
|
+
};
|
|
815
|
+
let checked = 0;
|
|
816
|
+
let first = null;
|
|
817
|
+
let previous = null;
|
|
818
|
+
let problemCount = 0;
|
|
819
|
+
for (;;) {
|
|
820
|
+
const links = await adapter.findMany({
|
|
821
|
+
model: "auditChain",
|
|
822
|
+
where: previous ? [{
|
|
823
|
+
field: "sequence",
|
|
824
|
+
value: previous.sequence,
|
|
825
|
+
operator: "gt"
|
|
826
|
+
}] : [],
|
|
827
|
+
limit: VERIFY_PAGE_SIZE,
|
|
828
|
+
sortBy: {
|
|
829
|
+
field: "sequence",
|
|
830
|
+
direction: "asc"
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
if (links.length === 0) break;
|
|
834
|
+
const events = await readEvents(links.map((link) => link.eventId));
|
|
835
|
+
for (const link of links) {
|
|
836
|
+
checked++;
|
|
837
|
+
first ??= link;
|
|
838
|
+
if (previous) {
|
|
839
|
+
if (link.sequence !== previous.sequence + 1) {
|
|
840
|
+
report(link, "sequence-gap");
|
|
841
|
+
problemCount++;
|
|
842
|
+
} else if (link.previousHash !== previous.hash) {
|
|
843
|
+
report(link, "broken-link");
|
|
844
|
+
problemCount++;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const event = events.get(link.eventId);
|
|
848
|
+
if (!event) {
|
|
849
|
+
report(link, "missing-event");
|
|
850
|
+
problemCount++;
|
|
851
|
+
} else if (await linkHash(secret, event, link.sequence, link.previousHash) !== link.hash) {
|
|
852
|
+
report(link, "hash-mismatch");
|
|
853
|
+
problemCount++;
|
|
854
|
+
}
|
|
855
|
+
previous = link;
|
|
856
|
+
}
|
|
857
|
+
if (links.length < VERIFY_PAGE_SIZE) break;
|
|
858
|
+
}
|
|
859
|
+
let unchainedEvents = 0;
|
|
860
|
+
if (first && previous) {
|
|
861
|
+
const firstEvent = (await readEvents([first.eventId])).get(first.eventId);
|
|
862
|
+
if (firstEvent) {
|
|
863
|
+
const stored = await adapter.count({
|
|
864
|
+
model: "auditEvent",
|
|
865
|
+
where: [{
|
|
866
|
+
field: "createdAt",
|
|
867
|
+
value: firstEvent.createdAt,
|
|
868
|
+
operator: "gte"
|
|
869
|
+
}]
|
|
870
|
+
});
|
|
871
|
+
unchainedEvents = Math.max(0, stored - checked);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
valid: problemCount === 0 && unchainedEvents === 0,
|
|
876
|
+
checked,
|
|
877
|
+
firstSequence: first?.sequence ?? null,
|
|
878
|
+
lastSequence: previous?.sequence ?? null,
|
|
879
|
+
lastHash: previous?.hash ?? null,
|
|
880
|
+
problems,
|
|
881
|
+
unchainedEvents
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
link,
|
|
886
|
+
verify,
|
|
887
|
+
deleteOlderThan
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
// src/store.ts
|
|
891
|
+
function toEvent(row) {
|
|
892
|
+
let metadata = null;
|
|
893
|
+
if (row.metadata) try {
|
|
894
|
+
metadata = JSON.parse(row.metadata);
|
|
895
|
+
} catch {
|
|
896
|
+
metadata = null;
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
id: row.id,
|
|
900
|
+
type: row.type,
|
|
901
|
+
status: row.status,
|
|
902
|
+
severity: row.severity,
|
|
903
|
+
userId: row.userId ?? null,
|
|
904
|
+
actorUserId: row.actorUserId ?? null,
|
|
905
|
+
sessionId: row.sessionId ?? null,
|
|
906
|
+
organizationId: row.organizationId ?? null,
|
|
907
|
+
ipAddress: row.ipAddress ?? null,
|
|
908
|
+
userAgent: row.userAgent ?? null,
|
|
909
|
+
errorCode: row.errorCode ?? null,
|
|
910
|
+
metadata,
|
|
911
|
+
createdAt: new Date(row.createdAt)
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
function filters(query) {
|
|
915
|
+
const where = [];
|
|
916
|
+
if (query.userId) where.push({
|
|
917
|
+
field: "userId",
|
|
918
|
+
value: query.userId
|
|
919
|
+
});
|
|
920
|
+
if (query.actorUserId) where.push({
|
|
921
|
+
field: "actorUserId",
|
|
922
|
+
value: query.actorUserId
|
|
923
|
+
});
|
|
924
|
+
if (query.type) where.push({
|
|
925
|
+
field: "type",
|
|
926
|
+
value: query.type
|
|
927
|
+
});
|
|
928
|
+
if (query.status) where.push({
|
|
929
|
+
field: "status",
|
|
930
|
+
value: query.status
|
|
931
|
+
});
|
|
932
|
+
if (query.from) where.push({
|
|
933
|
+
field: "createdAt",
|
|
934
|
+
value: query.from,
|
|
935
|
+
operator: "gte"
|
|
936
|
+
});
|
|
937
|
+
if (query.to) where.push({
|
|
938
|
+
field: "createdAt",
|
|
939
|
+
value: query.to,
|
|
940
|
+
operator: "lt"
|
|
941
|
+
});
|
|
942
|
+
return where;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* The default storage: the plugin's `auditEvent` table, through
|
|
946
|
+
* better-auth's adapter. Inside a transaction (database hooks), writes go
|
|
947
|
+
* through the transaction's adapter, so SQLite doesn't wait on itself.
|
|
948
|
+
*/
|
|
949
|
+
function databaseStorage(baseAdapter) {
|
|
950
|
+
const adapter = () => getCurrentAdapter(baseAdapter);
|
|
951
|
+
return {
|
|
952
|
+
async write(event) {
|
|
953
|
+
return toEvent(await (await adapter()).create({
|
|
954
|
+
model: "auditEvent",
|
|
955
|
+
data: {
|
|
956
|
+
...event,
|
|
957
|
+
metadata: event.metadata ? JSON.stringify(event.metadata) : null
|
|
958
|
+
}
|
|
959
|
+
}));
|
|
960
|
+
},
|
|
961
|
+
async read(query) {
|
|
962
|
+
const db = await adapter();
|
|
963
|
+
const where = filters(query);
|
|
964
|
+
const [rows, total] = await Promise.all([db.findMany({
|
|
965
|
+
model: "auditEvent",
|
|
966
|
+
where,
|
|
967
|
+
limit: query.limit,
|
|
968
|
+
offset: query.offset,
|
|
969
|
+
sortBy: {
|
|
970
|
+
field: "createdAt",
|
|
971
|
+
direction: "desc"
|
|
972
|
+
}
|
|
973
|
+
}), db.count({
|
|
974
|
+
model: "auditEvent",
|
|
975
|
+
where
|
|
976
|
+
})]);
|
|
977
|
+
return {
|
|
978
|
+
events: rows.map(toEvent),
|
|
979
|
+
total
|
|
980
|
+
};
|
|
981
|
+
},
|
|
982
|
+
async readByIds(ids) {
|
|
983
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
984
|
+
const rows = await (await adapter()).findMany({
|
|
985
|
+
model: "auditEvent",
|
|
986
|
+
where: [{
|
|
987
|
+
field: "id",
|
|
988
|
+
value: ids,
|
|
989
|
+
operator: "in"
|
|
990
|
+
}]
|
|
991
|
+
});
|
|
992
|
+
return new Map(rows.map((row) => [row.id, toEvent(row)]));
|
|
993
|
+
},
|
|
994
|
+
async deleteOlderThan(date) {
|
|
995
|
+
return (await adapter()).deleteMany({
|
|
996
|
+
model: "auditEvent",
|
|
997
|
+
where: [{
|
|
998
|
+
field: "createdAt",
|
|
999
|
+
value: date,
|
|
1000
|
+
operator: "lt"
|
|
1001
|
+
}]
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
// src/utils/redact.ts
|
|
1007
|
+
/** Keys whose values never reach the log, whatever their nesting or case. */
|
|
1008
|
+
const SECRET_KEY = /password|passphrase|secret|token|^otp$|backup.?codes?|api.?key|authorization|cookie/i;
|
|
1009
|
+
const REDACTED = "[redacted]";
|
|
1010
|
+
const MAX_DEPTH = 5;
|
|
1011
|
+
/**
|
|
1012
|
+
* Copies metadata with secret-looking values replaced, so a careless
|
|
1013
|
+
* `recordAuditEvent` call can't put a password or token in the log.
|
|
1014
|
+
*/
|
|
1015
|
+
function redact(value, depth = 0) {
|
|
1016
|
+
if (depth > MAX_DEPTH) return REDACTED;
|
|
1017
|
+
if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1));
|
|
1018
|
+
if (value instanceof Date) return value.toISOString();
|
|
1019
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, SECRET_KEY.test(key) ? REDACTED : redact(item, depth + 1)]));
|
|
1020
|
+
return value;
|
|
1021
|
+
}
|
|
1022
|
+
// src/utils/severity.ts
|
|
1023
|
+
const BUILT_IN = {
|
|
1024
|
+
"sign-up": "low",
|
|
1025
|
+
"sign-in": "low",
|
|
1026
|
+
"sign-out": "low",
|
|
1027
|
+
"email-verified": "low",
|
|
1028
|
+
"session-revoked": "medium",
|
|
1029
|
+
"account-linked": "medium",
|
|
1030
|
+
"account-unlinked": "medium",
|
|
1031
|
+
"email-changed": "medium",
|
|
1032
|
+
"password-changed": "medium",
|
|
1033
|
+
"password-reset": "medium",
|
|
1034
|
+
"user-deleted": "high",
|
|
1035
|
+
"two-factor-enabled": "medium",
|
|
1036
|
+
"two-factor-disabled": "high",
|
|
1037
|
+
"organization.deleted": "high",
|
|
1038
|
+
"organization.member-removed": "medium",
|
|
1039
|
+
"organization.member-role-changed": "high",
|
|
1040
|
+
"admin.set-role": "critical",
|
|
1041
|
+
"admin.set-user-password": "critical",
|
|
1042
|
+
"admin.impersonate-user": "critical"
|
|
1043
|
+
};
|
|
1044
|
+
/**
|
|
1045
|
+
* The severity of an event: the `severity` option, then the built-in
|
|
1046
|
+
* table. Admin actions are `high` unless listed; failed sign-ins `medium`;
|
|
1047
|
+
* anything else `low`.
|
|
1048
|
+
*/
|
|
1049
|
+
function severityOf(type, status, overrides) {
|
|
1050
|
+
const override = overrides[type];
|
|
1051
|
+
if (override) return override;
|
|
1052
|
+
if (type === "sign-in" && status === "failure") return "medium";
|
|
1053
|
+
return BUILT_IN[type] ?? (type.startsWith("admin.") ? "high" : "low");
|
|
1054
|
+
}
|
|
1055
|
+
const MAX_USER_AGENT_LENGTH = 512;
|
|
1056
|
+
function createRecorder(options) {
|
|
1057
|
+
let lastSweep = 0;
|
|
1058
|
+
const chain = options.hashChain ? createChain(options.hashChain.secret) : null;
|
|
1059
|
+
let lastCreatedAt = 0;
|
|
1060
|
+
/**
|
|
1061
|
+
* The current time, but never the same as or earlier than the previous
|
|
1062
|
+
* event's: events recorded in the same millisecond (a sign-up and its
|
|
1063
|
+
* sign-in) still list in the order they happened.
|
|
1064
|
+
*/
|
|
1065
|
+
const nextTimestamp = () => {
|
|
1066
|
+
lastCreatedAt = Math.max(Date.now(), lastCreatedAt + 1);
|
|
1067
|
+
return new Date(lastCreatedAt);
|
|
1068
|
+
};
|
|
1069
|
+
const storageFor = (ctx) => options.storage ?? databaseStorage(ctx.adapter);
|
|
1070
|
+
const isRecorded = (type) => !options.events || options.events(type);
|
|
1071
|
+
function build(ctx, details, headers) {
|
|
1072
|
+
const status = details.status ?? "success";
|
|
1073
|
+
let metadata = details.metadata ? redact(details.metadata) : null;
|
|
1074
|
+
if (metadata && JSON.stringify(metadata).length > 4e3) metadata = { truncated: true };
|
|
1075
|
+
const ipAddress = details.ipAddress ?? (options.captureIp && headers ? getIP(headers, ctx.options) : null);
|
|
1076
|
+
const userAgent = details.userAgent ?? (options.captureUserAgent ? headers?.get("user-agent") ?? null : null);
|
|
1077
|
+
return {
|
|
1078
|
+
type: details.type,
|
|
1079
|
+
status,
|
|
1080
|
+
severity: details.severity ?? severityOf(details.type, status, options.severity),
|
|
1081
|
+
userId: details.userId ?? null,
|
|
1082
|
+
actorUserId: details.actorUserId ?? null,
|
|
1083
|
+
sessionId: details.sessionId ?? null,
|
|
1084
|
+
organizationId: details.organizationId ?? null,
|
|
1085
|
+
ipAddress,
|
|
1086
|
+
userAgent: userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null,
|
|
1087
|
+
errorCode: details.errorCode ?? null,
|
|
1088
|
+
metadata,
|
|
1089
|
+
createdAt: nextTimestamp()
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
/** Deletes events created before `date`, and their links. */
|
|
1093
|
+
async function deleteOlderThan(ctx, date) {
|
|
1094
|
+
const deleted = await storageFor(ctx).deleteOlderThan(date);
|
|
1095
|
+
if (chain) await chain.deleteOlderThan(ctx, date);
|
|
1096
|
+
return deleted;
|
|
1097
|
+
}
|
|
1098
|
+
/** Starts a retention sweep in the background, at most once per `sweepInterval`. */
|
|
1099
|
+
function maybeSweep(ctx) {
|
|
1100
|
+
if (!options.retention) return;
|
|
1101
|
+
const now = Date.now();
|
|
1102
|
+
if (now - lastSweep < options.retention.sweepInterval * 1e3) return;
|
|
1103
|
+
lastSweep = now;
|
|
1104
|
+
const sweep = deleteOlderThan(ctx, /* @__PURE__ */ new Date(now - options.retention.days * 864e5)).catch((error) => ctx.logger.error("[audit-log] retention sweep failed", error));
|
|
1105
|
+
ctx.runInBackground(sweep);
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Builds, filters, and stores an event, then calls `onEvent`. Returns
|
|
1109
|
+
* `null` if the event isn't recorded (`events`, `beforeLog`). Storage
|
|
1110
|
+
* errors are thrown; `onEvent` errors are logged.
|
|
1111
|
+
*/
|
|
1112
|
+
async function recordNow(ctx, details, headers) {
|
|
1113
|
+
if (!isRecorded(details.type)) return null;
|
|
1114
|
+
let event = build(ctx, details, headers);
|
|
1115
|
+
if (options.beforeLog) {
|
|
1116
|
+
try {
|
|
1117
|
+
event = await options.beforeLog(event);
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
ctx.logger.error("[audit-log] beforeLog threw an error; the event was dropped", error);
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
if (!event) return null;
|
|
1123
|
+
}
|
|
1124
|
+
const stored = await storageFor(ctx).write(event);
|
|
1125
|
+
if (chain) await chain.link(ctx, stored);
|
|
1126
|
+
if (options.onEvent) try {
|
|
1127
|
+
await options.onEvent(stored);
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
ctx.logger.error("[audit-log] onEvent threw an error", error);
|
|
1130
|
+
}
|
|
1131
|
+
maybeSweep(ctx);
|
|
1132
|
+
return stored;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* `recordNow` for hooks: never throws, and runs in the background when
|
|
1136
|
+
* better-auth's `advanced.backgroundTasks` is configured.
|
|
1137
|
+
*/
|
|
1138
|
+
async function record(ctx, details, headers) {
|
|
1139
|
+
if (!isRecorded(details.type)) return;
|
|
1140
|
+
const task = recordNow(ctx, details, headers).then(() => void 0, (error) => ctx.logger.error(`[audit-log] failed to record a "${details.type}" event`, error));
|
|
1141
|
+
await ctx.runInBackgroundOrAwait(task);
|
|
1142
|
+
}
|
|
1143
|
+
return {
|
|
1144
|
+
record,
|
|
1145
|
+
recordNow,
|
|
1146
|
+
storageFor,
|
|
1147
|
+
isRecorded,
|
|
1148
|
+
deleteOlderThan,
|
|
1149
|
+
chain
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
// ../plugin-utils/src/pagination.ts
|
|
1153
|
+
/**
|
|
1154
|
+
* The `limit` and `offset` fields of a list endpoint's query, coerced from
|
|
1155
|
+
* query strings. Spread into the endpoint's `z.object({ ... })`.
|
|
1156
|
+
*/
|
|
1157
|
+
function paginationQuery({ defaultLimit, maxLimit }) {
|
|
1158
|
+
return {
|
|
1159
|
+
limit: z.coerce.number().int().min(1).max(maxLimit).default(defaultLimit).meta({ description: `How many to return, at most ${maxLimit}. Defaults to ${defaultLimit}` }),
|
|
1160
|
+
offset: z.coerce.number().int().min(0).default(0).meta({ description: "How many to skip. Defaults to 0" })
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
const statusSchema = z.enum(["success", "failure"]);
|
|
1164
|
+
const severitySchema = z.enum([
|
|
1165
|
+
"low",
|
|
1166
|
+
"medium",
|
|
1167
|
+
"high",
|
|
1168
|
+
"critical"
|
|
1169
|
+
]);
|
|
1170
|
+
const typeSchema = z.string().max(64).refine(isValidEventType, { message: "Invalid event type" });
|
|
1171
|
+
const idSchema = z.string().min(1).max(255);
|
|
1172
|
+
function createEventEndpoints(options, recorder) {
|
|
1173
|
+
return {
|
|
1174
|
+
listAuditEvents: createAuthEndpoint("/audit-log/list-events", {
|
|
1175
|
+
method: "GET",
|
|
1176
|
+
use: [sessionMiddleware],
|
|
1177
|
+
query: z.optional(z.object({
|
|
1178
|
+
userId: idSchema.optional().meta({ description: "Events about this user. Admins only, except for yourself" }),
|
|
1179
|
+
actorUserId: idSchema.optional().meta({ description: "Events done by this user. Admins only" }),
|
|
1180
|
+
type: typeSchema.optional().meta({ description: "Only events of this type" }),
|
|
1181
|
+
status: statusSchema.optional().meta({ description: "Only events with this status" }),
|
|
1182
|
+
from: z.coerce.date().optional().meta({ description: "Events at or after this time" }),
|
|
1183
|
+
to: z.coerce.date().optional().meta({ description: "Events before this time" }),
|
|
1184
|
+
...paginationQuery({
|
|
1185
|
+
defaultLimit: 20,
|
|
1186
|
+
maxLimit: 100
|
|
1187
|
+
})
|
|
1188
|
+
})),
|
|
1189
|
+
metadata: { openapi: { description: "List audit events, newest first: your own, or any user's for admins" } }
|
|
1190
|
+
}, async (ctx) => {
|
|
1191
|
+
const me = ctx.context.session.user;
|
|
1192
|
+
const query = ctx.query ?? {
|
|
1193
|
+
limit: 20,
|
|
1194
|
+
offset: 0
|
|
1195
|
+
};
|
|
1196
|
+
const admin = isAdmin(me, options);
|
|
1197
|
+
if (!admin && (query.userId && query.userId !== me.id || query.actorUserId)) throw APIError.from("FORBIDDEN", AUDIT_LOG_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_OTHER_USERS_EVENTS);
|
|
1198
|
+
const { events, total } = await recorder.storageFor(ctx.context).read({
|
|
1199
|
+
userId: admin ? query.userId : me.id,
|
|
1200
|
+
actorUserId: query.actorUserId,
|
|
1201
|
+
type: query.type,
|
|
1202
|
+
status: query.status,
|
|
1203
|
+
from: query.from,
|
|
1204
|
+
to: query.to,
|
|
1205
|
+
limit: query.limit ?? 20,
|
|
1206
|
+
offset: query.offset ?? 0
|
|
1207
|
+
});
|
|
1208
|
+
return ctx.json({
|
|
1209
|
+
events: admin ? events : events.map((event) => event.actorUserId === me.id ? event : {
|
|
1210
|
+
...event,
|
|
1211
|
+
ipAddress: null,
|
|
1212
|
+
userAgent: null,
|
|
1213
|
+
sessionId: null
|
|
1214
|
+
}),
|
|
1215
|
+
total
|
|
1216
|
+
});
|
|
1217
|
+
}),
|
|
1218
|
+
/**
|
|
1219
|
+
* Server only (no path, so not reachable over HTTP): records an event
|
|
1220
|
+
* of your own, e.g. `auth.api.recordAuditEvent({ body: { type:
|
|
1221
|
+
* "billing.plan-changed", userId } })`.
|
|
1222
|
+
*/
|
|
1223
|
+
recordAuditEvent: createAuthEndpoint({
|
|
1224
|
+
method: "POST",
|
|
1225
|
+
body: z.object({
|
|
1226
|
+
type: typeSchema.meta({ description: "The event type, e.g. `billing.plan-changed`" }),
|
|
1227
|
+
status: statusSchema.optional(),
|
|
1228
|
+
severity: severitySchema.optional(),
|
|
1229
|
+
userId: idSchema.optional().meta({ description: "The user the event is about" }),
|
|
1230
|
+
actorUserId: idSchema.optional().meta({ description: "The user who did it" }),
|
|
1231
|
+
organizationId: idSchema.optional(),
|
|
1232
|
+
errorCode: z.string().max(255).optional(),
|
|
1233
|
+
metadata: z.record(z.string(), z.unknown()).optional()
|
|
1234
|
+
}),
|
|
1235
|
+
metadata: { openapi: { description: "Record an audit event. Server only" } }
|
|
1236
|
+
}, async (ctx) => {
|
|
1237
|
+
const { metadata } = ctx.body;
|
|
1238
|
+
if (metadata && JSON.stringify(metadata).length > 4e3) throw APIError.from("BAD_REQUEST", AUDIT_LOG_ERROR_CODES.METADATA_TOO_LARGE);
|
|
1239
|
+
const event = await recorder.recordNow(ctx.context, ctx.body, ctx.request?.headers ?? ctx.headers);
|
|
1240
|
+
return ctx.json({ event });
|
|
1241
|
+
}),
|
|
1242
|
+
/**
|
|
1243
|
+
* Server only: deletes old events, for a cron job. Uses `retention.days`
|
|
1244
|
+
* unless `days` is passed.
|
|
1245
|
+
*/
|
|
1246
|
+
deleteExpiredAuditEvents: createAuthEndpoint({
|
|
1247
|
+
method: "POST",
|
|
1248
|
+
body: z.optional(z.object({ days: z.number().positive().optional().meta({ description: "Delete events older than this" }) })),
|
|
1249
|
+
metadata: { openapi: { description: "Delete audit events older than the retention period. Server only" } }
|
|
1250
|
+
}, async (ctx) => {
|
|
1251
|
+
const days = ctx.body?.days ?? options.retention?.days;
|
|
1252
|
+
if (!days) throw APIError.from("BAD_REQUEST", AUDIT_LOG_ERROR_CODES.RETENTION_NOT_CONFIGURED);
|
|
1253
|
+
const deleted = await recorder.deleteOlderThan(ctx.context, /* @__PURE__ */ new Date(Date.now() - days * 864e5));
|
|
1254
|
+
return ctx.json({ deleted });
|
|
1255
|
+
}),
|
|
1256
|
+
/**
|
|
1257
|
+
* Server only: re-computes the hash chain and reports changed, deleted,
|
|
1258
|
+
* and unchained events. Reads every link, so run it from a job, not on
|
|
1259
|
+
* every request.
|
|
1260
|
+
*/
|
|
1261
|
+
verifyAuditChain: createAuthEndpoint({
|
|
1262
|
+
method: "POST",
|
|
1263
|
+
metadata: { openapi: { description: "Verify the audit log's hash chain. Server only" } }
|
|
1264
|
+
}, async (ctx) => {
|
|
1265
|
+
const { chain } = recorder;
|
|
1266
|
+
if (!chain) throw APIError.from("BAD_REQUEST", AUDIT_LOG_ERROR_CODES.HASH_CHAIN_NOT_ENABLED);
|
|
1267
|
+
const storage = databaseStorage(ctx.context.adapter);
|
|
1268
|
+
return ctx.json(await chain.verify(ctx.context, (ids) => storage.readByIds(ids)));
|
|
1269
|
+
})
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
// src/schema.ts
|
|
1273
|
+
const auditLogSchema = {
|
|
1274
|
+
/**
|
|
1275
|
+
* One row per event. User ids are plain columns, not foreign keys: the
|
|
1276
|
+
* log must outlive the users it mentions, `user-deleted` included.
|
|
1277
|
+
*/
|
|
1278
|
+
auditEvent: { fields: {
|
|
1279
|
+
/** `sign-in`, `admin.ban-user`, or an app-defined type. */
|
|
1280
|
+
type: {
|
|
1281
|
+
type: "string",
|
|
1282
|
+
required: true,
|
|
1283
|
+
index: true
|
|
1284
|
+
},
|
|
1285
|
+
/** "success" | "failure" */
|
|
1286
|
+
status: {
|
|
1287
|
+
type: "string",
|
|
1288
|
+
required: true
|
|
1289
|
+
},
|
|
1290
|
+
/** "low" | "medium" | "high" | "critical" */
|
|
1291
|
+
severity: {
|
|
1292
|
+
type: "string",
|
|
1293
|
+
required: true
|
|
1294
|
+
},
|
|
1295
|
+
/** The user the event is about. */
|
|
1296
|
+
userId: {
|
|
1297
|
+
type: "string",
|
|
1298
|
+
required: false,
|
|
1299
|
+
index: true
|
|
1300
|
+
},
|
|
1301
|
+
/** The user who did it: the user themselves, or an admin. */
|
|
1302
|
+
actorUserId: {
|
|
1303
|
+
type: "string",
|
|
1304
|
+
required: false,
|
|
1305
|
+
index: true
|
|
1306
|
+
},
|
|
1307
|
+
sessionId: {
|
|
1308
|
+
type: "string",
|
|
1309
|
+
required: false
|
|
1310
|
+
},
|
|
1311
|
+
/** The session's active organization (organization plugin). */
|
|
1312
|
+
organizationId: {
|
|
1313
|
+
type: "string",
|
|
1314
|
+
required: false
|
|
1315
|
+
},
|
|
1316
|
+
ipAddress: {
|
|
1317
|
+
type: "string",
|
|
1318
|
+
required: false
|
|
1319
|
+
},
|
|
1320
|
+
userAgent: {
|
|
1321
|
+
type: "string",
|
|
1322
|
+
required: false
|
|
1323
|
+
},
|
|
1324
|
+
/** The error code of a failed request, e.g. `INVALID_EMAIL_OR_PASSWORD`. */
|
|
1325
|
+
errorCode: {
|
|
1326
|
+
type: "string",
|
|
1327
|
+
required: false
|
|
1328
|
+
},
|
|
1329
|
+
/** JSON-encoded. */
|
|
1330
|
+
metadata: {
|
|
1331
|
+
type: "string",
|
|
1332
|
+
required: false
|
|
1333
|
+
},
|
|
1334
|
+
createdAt: {
|
|
1335
|
+
type: "date",
|
|
1336
|
+
required: true,
|
|
1337
|
+
index: true
|
|
1338
|
+
}
|
|
1339
|
+
} } };
|
|
1340
|
+
/** Added with the `hashChain` option: one link per event, in order. */
|
|
1341
|
+
const auditChainSchema = { auditChain: {
|
|
1342
|
+
fields: {
|
|
1343
|
+
/** 1, 2, 3, ... Unique, so concurrent writers can't fork the chain. */
|
|
1344
|
+
sequence: {
|
|
1345
|
+
type: "number",
|
|
1346
|
+
required: true
|
|
1347
|
+
},
|
|
1348
|
+
/** The event's id. Not a foreign key: a deleted event must show as missing. */
|
|
1349
|
+
eventId: {
|
|
1350
|
+
type: "string",
|
|
1351
|
+
required: true
|
|
1352
|
+
},
|
|
1353
|
+
previousHash: {
|
|
1354
|
+
type: "string",
|
|
1355
|
+
required: true
|
|
1356
|
+
},
|
|
1357
|
+
/** HMAC-SHA256 of the event, the sequence number, and `previousHash`. */
|
|
1358
|
+
hash: {
|
|
1359
|
+
type: "string",
|
|
1360
|
+
required: true
|
|
1361
|
+
},
|
|
1362
|
+
/** The event's `createdAt`, for retention. */
|
|
1363
|
+
createdAt: {
|
|
1364
|
+
type: "date",
|
|
1365
|
+
required: true,
|
|
1366
|
+
index: true
|
|
1367
|
+
}
|
|
1368
|
+
},
|
|
1369
|
+
indexes: [{
|
|
1370
|
+
fields: ["sequence"],
|
|
1371
|
+
unique: true
|
|
1372
|
+
}]
|
|
1373
|
+
} };
|
|
1374
|
+
// src/memory.ts
|
|
1375
|
+
/**
|
|
1376
|
+
* Keeps events in memory, newest last. For tests and demos: events are
|
|
1377
|
+
* lost on restart and not shared between server instances.
|
|
1378
|
+
*
|
|
1379
|
+
* @example
|
|
1380
|
+
* ```ts
|
|
1381
|
+
* const storage = memoryStorage();
|
|
1382
|
+
* betterAuth({ plugins: [auditLog({ storage })] });
|
|
1383
|
+
* storage.events; // every stored event
|
|
1384
|
+
* ```
|
|
1385
|
+
*/
|
|
1386
|
+
function memoryStorage() {
|
|
1387
|
+
const events = [];
|
|
1388
|
+
const matches = (event, query) => (!query.userId || event.userId === query.userId) && (!query.actorUserId || event.actorUserId === query.actorUserId) && (!query.type || event.type === query.type) && (!query.status || event.status === query.status) && (!query.from || event.createdAt >= query.from) && (!query.to || event.createdAt < query.to);
|
|
1389
|
+
return {
|
|
1390
|
+
events,
|
|
1391
|
+
async write(input) {
|
|
1392
|
+
const event = {
|
|
1393
|
+
...input,
|
|
1394
|
+
id: crypto.randomUUID()
|
|
1395
|
+
};
|
|
1396
|
+
events.push(event);
|
|
1397
|
+
return event;
|
|
1398
|
+
},
|
|
1399
|
+
async read(query) {
|
|
1400
|
+
const found = events.filter((event) => matches(event, query)).reverse();
|
|
1401
|
+
return {
|
|
1402
|
+
events: found.slice(query.offset, query.offset + query.limit),
|
|
1403
|
+
total: found.length
|
|
1404
|
+
};
|
|
1405
|
+
},
|
|
1406
|
+
async deleteOlderThan(date) {
|
|
1407
|
+
const kept = events.filter((event) => event.createdAt >= date);
|
|
1408
|
+
const deleted = events.length - kept.length;
|
|
1409
|
+
events.splice(0, events.length, ...kept);
|
|
1410
|
+
return deleted;
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
// src/index.ts
|
|
1415
|
+
/** Plugins whose after-hooks clear `ctx.context.newSession` (a sign-in that isn't done yet). */
|
|
1416
|
+
const CLEARS_NEW_SESSION = /* @__PURE__ */ new Set(["two-factor"]);
|
|
1417
|
+
/**
|
|
1418
|
+
* Sign-ins are read from `ctx.context.newSession` after the endpoint ran,
|
|
1419
|
+
* which plugins such as `twoFactor` clear in their own after-hooks (the
|
|
1420
|
+
* sign-in isn't done until the second factor). Hooks run in plugin order,
|
|
1421
|
+
* so the audit log must come after them.
|
|
1422
|
+
*/
|
|
1423
|
+
function warnAboutOrder(ctx) {
|
|
1424
|
+
const plugins = ctx.options.plugins ?? [];
|
|
1425
|
+
const index = plugins.findIndex((plugin) => plugin.id === "audit-log");
|
|
1426
|
+
const later = plugins.slice(index + 1).filter((plugin) => CLEARS_NEW_SESSION.has(plugin.id)).map((plugin) => plugin.id);
|
|
1427
|
+
if (later.length > 0) ctx.logger.warn(`[audit-log] Put auditLog() after ${later.join(", ")} in "plugins", so sign-ins are recorded once they are complete.`);
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Records security-relevant events: sign-ins (any method) and failed
|
|
1431
|
+
* sign-ins, sign-ups, sign-outs, revoked sessions, password and email
|
|
1432
|
+
* changes, linked accounts, deleted users, and the actions of better-auth's
|
|
1433
|
+
* `admin` plugin. Users can list their own events, admins everyone's.
|
|
1434
|
+
*
|
|
1435
|
+
* @example
|
|
1436
|
+
* ```ts
|
|
1437
|
+
* import { betterAuth } from "better-auth";
|
|
1438
|
+
* import { auditLog } from "@checodo/better-auth-audit-log";
|
|
1439
|
+
*
|
|
1440
|
+
* export const auth = betterAuth({
|
|
1441
|
+
* plugins: [
|
|
1442
|
+
* // ...other plugins, then:
|
|
1443
|
+
* auditLog({ retention: { days: 90 } }),
|
|
1444
|
+
* ],
|
|
1445
|
+
* });
|
|
1446
|
+
* ```
|
|
1447
|
+
*/
|
|
1448
|
+
const auditLog = (options = {}) => {
|
|
1449
|
+
const resolved = resolveOptions(options);
|
|
1450
|
+
const recorder = createRecorder(resolved);
|
|
1451
|
+
const plugin = {
|
|
1452
|
+
id: "audit-log",
|
|
1453
|
+
version,
|
|
1454
|
+
init(ctx) {
|
|
1455
|
+
warnAboutOrder(ctx);
|
|
1456
|
+
return { options: { databaseHooks: createDatabaseHooks(ctx, recorder) } };
|
|
1457
|
+
},
|
|
1458
|
+
...resolved.storage ? {} : { schema: mergeSchema(structuredClone({
|
|
1459
|
+
...auditLogSchema,
|
|
1460
|
+
...resolved.hashChain ? auditChainSchema : {}
|
|
1461
|
+
}), options.schema) },
|
|
1462
|
+
endpoints: createEventEndpoints(resolved, recorder),
|
|
1463
|
+
hooks: createPathHooks(resolved, recorder),
|
|
1464
|
+
$ERROR_CODES: AUDIT_LOG_ERROR_CODES,
|
|
1465
|
+
options
|
|
1466
|
+
};
|
|
1467
|
+
return Object.assign(plugin, {
|
|
1468
|
+
/**
|
|
1469
|
+
* Records an event from another plugin or your own server code, with
|
|
1470
|
+
* the current request's user, session, IP address, and user agent:
|
|
1471
|
+
* `ctx.context.getPlugin("audit-log")?.recordEvent({ type: "x.y", userId })`.
|
|
1472
|
+
* Never throws.
|
|
1473
|
+
*/
|
|
1474
|
+
recordEvent: createRecordEvent(recorder) });
|
|
1475
|
+
};
|
|
1476
|
+
export { AUDIT_LOG_ERROR_CODES, auditLog, memoryStorage };
|
|
1477
|
+
|
|
1478
|
+
//# sourceMappingURL=index.js.map
|