feat: security and auditing

This commit is contained in:
2026-02-04 16:48:08 -08:00
parent 11be34cd21
commit 0a654f423a
42 changed files with 2195 additions and 160 deletions
+103
View File
@@ -0,0 +1,103 @@
import type { FastifyRequest } from "fastify";
import { prisma } from "../lib/prisma";
import type { AuditAction, AuditCategory, AuditLogFilters } from "@library/shared-types";
interface AuditLogData {
action: AuditAction;
category: AuditCategory;
userId?: string;
targetUserId?: string;
resourceType?: string;
resourceId?: string;
details?: string;
success?: boolean;
}
export const AuditService = {
async log(data: AuditLogData, request?: FastifyRequest) {
const userAgent = request?.headers["user-agent"] ?? undefined;
return prisma.auditLog.create({
data: {
action: data.action,
category: data.category,
userId: data.userId,
targetUserId: data.targetUserId,
resourceType: data.resourceType,
resourceId: data.resourceId,
details: data.details,
userAgent,
success: data.success ?? true,
},
});
},
async logFromRequest(
request: FastifyRequest,
data: Omit<AuditLogData, "userId">
) {
const userId = (request.user as { id?: string } | undefined)?.id;
return this.log(
{
...data,
userId,
},
request
);
},
async getLogs(filters: AuditLogFilters = {}) {
const { action, category, userId, success, startDate, endDate, page = 1, limit = 50 } = filters;
const where: Record<string, unknown> = {};
if (action) {
where.action = action;
}
if (category) {
where.category = category;
}
if (userId) {
where.userId = userId;
}
if (success !== undefined) {
where.success = success;
}
if (startDate || endDate) {
where.createdAt = {};
if (startDate) {
(where.createdAt as Record<string, Date>).gte = startDate;
}
if (endDate) {
(where.createdAt as Record<string, Date>).lte = endDate;
}
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
}),
prisma.auditLog.count({ where }),
]);
return {
logs,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
},
async getLogsByUser(userId: string, page = 1, limit = 50) {
return this.getLogs({ userId, page, limit });
},
async getSecurityLogs(page = 1, limit = 50) {
return this.getLogs({ category: "SECURITY" as AuditCategory, page, limit });
},
};
+24
View File
@@ -30,6 +30,29 @@ export class AuthService {
return this.app.jwt.verify(token) as JwtPayload;
}
/**
* Get user by ID from database.
*/
async getUserById(id: string): Promise<User | null> {
const dbUser = await this.prisma.user.findUnique({
where: { id },
});
if (!dbUser) {
return null;
}
return {
id: dbUser.id,
discordId: dbUser.discordId,
username: dbUser.username,
email: dbUser.email,
avatarUrl: dbUser.avatar || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
};
}
/**
* Create or update user from Discord OAuth data.
*/
@@ -64,6 +87,7 @@ export class AuthService {
email: dbUser.email,
avatarUrl: dbUser.avatar || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
};
}
}
+2 -1
View File
@@ -9,4 +9,5 @@ export { GameService } from "./game.service";
export { BookService } from "./book.service";
export { MusicService } from "./music.service";
export { ShowService } from "./show.service";
export { MangaService } from "./manga.service";
export { MangaService } from "./manga.service";
export { AuditService } from "./audit.service";
+91
View File
@@ -0,0 +1,91 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { User } from "@library/shared-types";
import { prisma } from "../lib/prisma";
export class UserService {
private prisma = prisma;
async getAllUsers(): Promise<User[]> {
const users = await this.prisma.user.findMany({
orderBy: { username: "asc" },
});
return users.map((user) => ({
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
}));
}
async getUserById(id: string): Promise<User | null> {
const user = await this.prisma.user.findUnique({
where: { id },
});
if (!user) {
return null;
}
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
};
}
async banUser(id: string): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: { isBanned: true },
});
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
};
}
async unbanUser(id: string): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: { isBanned: false },
});
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
};
}
async isUserBanned(id: string): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id },
select: { isBanned: true },
});
return user?.isBanned ?? false;
}
}