diff --git a/packages/admin/src/components/sidebar/Sidebar.tsx b/packages/admin/src/components/sidebar/Sidebar.tsx index 9e8dc2d..15a6eb5 100644 --- a/packages/admin/src/components/sidebar/Sidebar.tsx +++ b/packages/admin/src/components/sidebar/Sidebar.tsx @@ -9,6 +9,7 @@ import { faCog, faHashtag, faHome, + faList, faServer, faShieldHalved, faSquare, @@ -54,6 +55,12 @@ export const SidebarWrapper = () => { isActive={pathname === "/"} href="/" /> + } + isActive={pathname === "/audit"} + href="/audit" + /> } title="Stats" diff --git a/packages/admin/src/main.tsx b/packages/admin/src/main.tsx index 2eff391..b1a8e3b 100644 --- a/packages/admin/src/main.tsx +++ b/packages/admin/src/main.tsx @@ -9,6 +9,7 @@ import { HomePage } from "./pages/Home/page.tsx"; import { AccountsPage } from "./pages/Accounts/Accounts/page.tsx"; import { ServiceSettingsPage } from "./pages/Service/settings.tsx"; import { ToastContainer } from "react-toastify"; +import { AuditLog } from "./pages/AuditLog/auditlog.tsx"; const router = createBrowserRouter( [ @@ -28,6 +29,10 @@ const router = createBrowserRouter( path: "/service/settings", element: , }, + { + path: "/audit", + element: , + }, ], }, ], diff --git a/packages/admin/src/pages/AuditLog/auditlog.tsx b/packages/admin/src/pages/AuditLog/auditlog.tsx new file mode 100644 index 0000000..e2a2d65 --- /dev/null +++ b/packages/admin/src/pages/AuditLog/auditlog.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { api, handleError } from "../../lib/utils"; +import { + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from "@nextui-org/react"; + +type AuditLogAction = "BAN_CREATE" | "BAN_UPDATE" | "BAN_DELETE"; + +type AuditLog = { + id: number; + userId: string; + action: AuditLogAction; + reason?: string; + comment?: string; + + banId?: number; + + createdAt: string; + updatedAt?: string; +}; + +export const AuditLog = () => { + const [auditLogs, setAuditLogs] = useState([]); + + useEffect(() => { + api<{ auditLogs: AuditLog[] }>("/api/admin/audit", "GET").then( + ({ status, data }) => { + if (status === 200) { + if (data.success) { + setAuditLogs(data.auditLogs); + } else { + handleError(status, data); + } + } else { + handleError(status, data); + } + } + ); + }, []); + + return ( + <> +

Audit Log

+
+ + + ID + User ID + Action + Reason + Comment + Created At / Updated At + + + {auditLogs.map((log) => ( + + {log.id} + {log.userId} + {log.action} + {log.reason} + {log.comment} + + {log.createdAt} / {log.updatedAt} + + + ))} + +
+
+ + ); +}; diff --git a/packages/server/prisma/dbml/schema.dbml b/packages/server/prisma/dbml/schema.dbml index 7bb50e8..092e054 100644 --- a/packages/server/prisma/dbml/schema.dbml +++ b/packages/server/prisma/dbml/schema.dbml @@ -20,6 +20,7 @@ Table User { pixels Pixel [not null] FactionMember FactionMember [not null] Ban Ban + AuditLog AuditLog [not null] } Table Instance { @@ -109,8 +110,30 @@ Table Ban { privateNote String publicNote String expiresAt DateTime [not null] + createdAt DateTime [default: `now()`, not null] + updatedAt DateTime user User instance Instance + AuditLog AuditLog [not null] +} + +Table AuditLog { + id Int [pk, increment] + userId String + action AuditLogAction [not null] + reason String + comment String + banId Int + createdAt DateTime [default: `now()`, not null] + updatedAt DateTime + user User + ban Ban +} + +Enum AuditLogAction { + BAN_CREATE + BAN_UPDATE + BAN_DELETE } Ref: Pixel.userId > User.sub @@ -129,4 +152,8 @@ Ref: FactionSetting.factionId > Faction.id Ref: Ban.userId - User.sub -Ref: Ban.instanceId - Instance.id \ No newline at end of file +Ref: Ban.instanceId - Instance.id + +Ref: AuditLog.userId > User.sub + +Ref: AuditLog.banId > Ban.id \ No newline at end of file diff --git a/packages/server/prisma/migrations/20240707193624_add_audit_log_model/migration.sql b/packages/server/prisma/migrations/20240707193624_add_audit_log_model/migration.sql new file mode 100644 index 0000000..f5c9f26 --- /dev/null +++ b/packages/server/prisma/migrations/20240707193624_add_audit_log_model/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "AuditLogAction" AS ENUM ('BAN_CREATE', 'BAN_UPDATE', 'BAN_DELETE'); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" SERIAL NOT NULL, + "userId" TEXT, + "action" "AuditLogAction" NOT NULL, + "reason" TEXT, + "comment" TEXT, + "banId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3), + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("sub") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_banId_fkey" FOREIGN KEY ("banId") REFERENCES "Ban"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/server/prisma/migrations/20240707200036_add_dates_to_bans/migration.sql b/packages/server/prisma/migrations/20240707200036_add_dates_to_bans/migration.sql new file mode 100644 index 0000000..eeef322 --- /dev/null +++ b/packages/server/prisma/migrations/20240707200036_add_dates_to_bans/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Ban" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "deletedAt" TIMESTAMP(3), +ADD COLUMN "updatedAt" TIMESTAMP(3); diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 5c34498..154ed7d 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -35,6 +35,7 @@ model User { pixels Pixel[] FactionMember FactionMember[] Ban Ban? + AuditLog AuditLog[] } model Instance { @@ -139,8 +140,32 @@ model Ban { publicNote String? expiresAt DateTime - // TODO: link audit log + createdAt DateTime @default(now()) + updatedAt DateTime? - user User? @relation(fields: [userId], references: [sub]) - instance Instance? @relation(fields: [instanceId], references: [id]) + user User? @relation(fields: [userId], references: [sub]) + instance Instance? @relation(fields: [instanceId], references: [id]) + AuditLog AuditLog[] +} + +enum AuditLogAction { + BAN_CREATE + BAN_UPDATE + BAN_DELETE +} + +model AuditLog { + id Int @id @default(autoincrement()) + userId String? + action AuditLogAction + reason String? + comment String? // service comment + + banId Int? + + createdAt DateTime @default(now()) + updatedAt DateTime? + + user User? @relation(fields: [userId], references: [sub]) + ban Ban? @relation(fields: [banId], references: [id]) } diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts index 7099cc0..72812a3 100644 --- a/packages/server/src/api/admin.ts +++ b/packages/server/src/api/admin.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { User, UserNotFound } from "../models/User"; +import { User, UserNotBanned, UserNotFound } from "../models/User"; import Canvas from "../lib/Canvas"; import { getLogger } from "../lib/Logger"; import { RateLimiter } from "../lib/RateLimiter"; @@ -10,6 +10,7 @@ import { InstanceNotBanned, InstanceNotFound, } from "../models/Instance"; +import { AuditLog } from "../models/AuditLog"; const app = Router(); const Logger = getLogger("HTTP/ADMIN"); @@ -206,6 +207,15 @@ app.put("/canvas/fill", async (req, res) => { res.json({ success: true }); }); +/** + * Create or ban a user + * + * @header X-Audit + * @param :sub User sub claim + * @body expiresAt string! ISO date time string + * @body publicNote string? + * @body privateNote string? + */ app.put("/user/:sub/ban", async (req, res) => { let user: User; let expires: Date; @@ -270,23 +280,8 @@ app.put("/user/:sub/ban", async (req, res) => { privateNote = req.body.privateNote; } - const existingBan = user.ban; - - const ban = await prisma.ban.upsert({ - where: { userId: user.sub }, - create: { - userId: user.sub, - expiresAt: expires, - publicNote, - privateNote, - }, - update: { - expiresAt: expires, - publicNote, - privateNote, - }, - }); - await user.update(true); + const existingBan = user.getBan(); + const ban = await user.ban(expires, publicNote, privateNote); let shouldNotifyUser = false; @@ -312,11 +307,27 @@ app.put("/user/:sub/ban", async (req, res) => { user.updateStanding(); - // todo: audit log + const adminUser = (await User.fromAuthSession(req.session.user!))!; + const audit = await AuditLog.Factory(adminUser.sub) + .doing(existingBan ? "BAN_UPDATE" : "BAN_CREATE") + .reason(req.header("X-Audit") || null) + .withComment( + existingBan + ? `Updated ban on ${user.sub}` + : `Created a ban for ${user.sub}` + ) + .withBan(ban) + .create(); - res.json({ success: true }); + res.json({ success: true, audit }); }); +/** + * Delete a user ban + * + * @header X-Audit + * @param :sub User sub + */ app.delete("/user/:sub/ban", async (req, res) => { // delete ban ("unban") @@ -334,18 +345,20 @@ app.delete("/user/:sub/ban", async (req, res) => { return; } - if (!user.ban?.id) { - res.status(400).json({ - success: false, - error: "User is not banned", - }); + try { + await user.unban(); + } catch (e) { + if (e instanceof UserNotBanned) { + res.status(404).json({ success: false, error: "User is not banned" }); + } else { + Logger.error( + `/instance/${req.params.sub}/ban Error ` + (e as any)?.message + ); + res.status(500).json({ success: false, error: "Internal error" }); + } return; } - await prisma.ban.delete({ - where: { id: user.ban.id }, - }); - user.notify({ is: "modal", action: "moderation", @@ -357,9 +370,14 @@ app.delete("/user/:sub/ban", async (req, res) => { await user.update(true); user.updateStanding(); - // todo: audit log + const adminUser = (await User.fromAuthSession(req.session.user!))!; + const audit = await AuditLog.Factory(adminUser.sub) + .doing("BAN_DELETE") + .reason(req.header("X-Audit") || null) + .withComment(`Deleted ban for ${user.sub}`) + .create(); - res.json({ success: true }); + res.json({ success: true, audit }); }); app.get("/instance/:domain/ban", async (req, res) => { @@ -392,6 +410,15 @@ app.get("/instance/:domain/ban", async (req, res) => { res.json({ success: true, ban }); }); +/** + * Create or update a ban for an instance (and subdomains) + * + * @header X-Audit + * @param :domain Domain for the instance + * @body expiresAt string! ISO date time string + * @body publicNote string? + * @body privateNote string? + */ app.put("/instance/:domain/ban", async (req, res) => { // ban domain & subdomains @@ -460,15 +487,34 @@ app.put("/instance/:domain/ban", async (req, res) => { privateNote = req.body.privateNote; } - await instance.ban(expires, publicNote, privateNote); + const hasExistingBan = await instance.getBan(); - // todo: audit log + const user = (await User.fromAuthSession(req.session.user!))!; + const ban = await instance.ban(expires, publicNote, privateNote); + const audit = await AuditLog.Factory(user.sub) + .doing(hasExistingBan ? "BAN_UPDATE" : "BAN_CREATE") + .reason(req.header("X-Audit") || null) + .withComment( + hasExistingBan + ? `Updated ban for ${instance.hostname}` + : `Created a ban for ${instance.hostname}` + ) + .withBan(ban) + .create(); res.json({ success: true, + ban, + audit, }); }); +/** + * Delete an instance ban + * + * @header X-Audit + * @param :domain The instance domain + */ app.delete("/instance/:domain/ban", async (req, res) => { // unban domain & subdomains @@ -488,8 +534,9 @@ app.delete("/instance/:domain/ban", async (req, res) => { return; } + let ban; try { - await instance.unban(); + ban = await instance.unban(); } catch (e) { if (e instanceof InstanceNotBanned) { res.status(404).json({ success: false, error: "instance not banned" }); @@ -502,9 +549,104 @@ app.delete("/instance/:domain/ban", async (req, res) => { return; } - // todo: audit log + const user = (await User.fromAuthSession(req.session.user!))!; + const audit = await AuditLog.Factory(user.sub) + .doing("BAN_DELETE") + .reason(req.header("X-Audit") || null) + .withComment(`Deleted ban for ${instance.hostname}`) + .create(); - res.json({ success: true }); + res.json({ success: true, audit }); +}); + +/** + * Get all audit logs + * + * TODO: pagination + */ +app.get("/audit", async (req, res) => { + const auditLogs = await prisma.auditLog.findMany({ + orderBy: { + createdAt: "desc", + }, + }); + + res.json({ success: true, auditLogs }); +}); + +/** + * Get audit log entry by ID + * + * @param :id Audit log ID + */ +app.get("/audit/:id", async (req, res) => { + let id = parseInt(req.params.id); + + if (isNaN(id)) { + return res + .status(400) + .json({ success: false, error: "id is not a number" }); + } + + const auditLog = await prisma.auditLog.findFirst({ where: { id } }); + + if (!auditLog) { + return res + .status(404) + .json({ success: false, error: "Audit log not found" }); + } + + res.json({ success: true, auditLog }); +}); + +/** + * Update audit log reason + * + * @param :id Audit log id + * @body reason string|null + */ +app.put("/audit/:id/reason", async (req, res) => { + let id = parseInt(req.params.id); + let reason: string; + + if (isNaN(id)) { + return res + .status(400) + .json({ success: false, error: "id is not a number" }); + } + + if (typeof req.body.reason !== "string" && req.body.reason !== null) { + return res + .status(400) + .json({ success: false, error: "reason is not a string or null" }); + } + + reason = req.body.reason; + + const auditLog = await prisma.auditLog.findFirst({ + where: { + id, + }, + }); + + if (!auditLog) { + return res + .status(404) + .json({ success: false, error: "audit log is not found" }); + } + + const newAudit = await prisma.auditLog.update({ + where: { id }, + data: { + reason, + updatedAt: new Date(), + }, + }); + + res.json({ + success: true, + auditLog: newAudit, + }); }); export default app; diff --git a/packages/server/src/lib/SocketServer.ts b/packages/server/src/lib/SocketServer.ts index 987669e..6163a85 100644 --- a/packages/server/src/lib/SocketServer.ts +++ b/packages/server/src/lib/SocketServer.ts @@ -173,22 +173,23 @@ export class SocketServer { if (socket.request.session.user) { // inform the client of their session if it exists socket.emit("user", socket.request.session.user); - - socket.emit( - "standing", - user?.ban - ? { - banned: true, - until: user.ban.expires.toISOString(), - reason: user.ban.publicNote || undefined, - } - : { banned: false } - ); } if (user) { socket.emit("availablePixels", user.pixelStack); socket.emit("pixelLastPlaced", user.lastPixelTime.getTime()); + + const ban = user.getBan(); + socket.emit( + "standing", + ban + ? { + banned: true, + until: ban.expires.toISOString(), + reason: ban.publicNote || undefined, + } + : { banned: false } + ); } socket.emit("config", getClientConfig()); @@ -261,7 +262,7 @@ export class SocketServer { return; } - if (user.ban && user.ban.expires > new Date()) { + if ((user.getBan()?.expires || 0) > new Date()) { ack({ success: false, error: "banned" }); return; } diff --git a/packages/server/src/lib/utils.ts b/packages/server/src/lib/utils.ts index 30c31af..bf9fc21 100644 --- a/packages/server/src/lib/utils.ts +++ b/packages/server/src/lib/utils.ts @@ -14,3 +14,8 @@ export const createEnum = (values: T[]): { [k in T]: k } => { return ret; }; + +export type ConditionalPromise< + T, + UsePromise extends boolean = false, +> = UsePromise extends true ? Promise : UsePromise extends false ? T : never; diff --git a/packages/server/src/models/AuditLog.ts b/packages/server/src/models/AuditLog.ts new file mode 100644 index 0000000..436c695 --- /dev/null +++ b/packages/server/src/models/AuditLog.ts @@ -0,0 +1,94 @@ +import { AuditLog as AuditLogDB, Ban, User } from "@prisma/client"; +import { prisma } from "../lib/prisma"; + +export class AuditLog { + static Factory(user: User | string | null) { + return new AuditLogFactory(user); + } + + static async createEmpty( + user: User, + action: AuditLogDB["action"], + reason?: string + ) { + return await prisma.auditLog.create({ + data: { + userId: user.sub, + action, + reason, + }, + }); + } +} + +class AuditLogFactory { + /** + * User who committed the action + * + * If null; the system did the action + * + * @nullable + */ + private _userId: string | null; + /** + * @required + */ + private _action?: AuditLogDB["action"]; + private _reason: string | null = null; + private _comment: string | null = null; + + /** + * Associated ban, if applicable + */ + private _ban?: Ban; + + constructor(user: User | string | null) { + if (typeof user === "string" || user === null) { + this._userId = user; + } else { + this._userId = user.sub; + } + } + + doing(action: AuditLogDB["action"]) { + this._action = action; + return this; + } + + reason(reason: string | null) { + this._reason = reason; + return this; + } + + /** + * Add comment from the service + * @param comment + * @returns + */ + withComment(comment: string | null) { + this._comment = comment; + return this; + } + + withBan(ban: Ban) { + this._ban = ban; + return this; + } + + async create() { + if (!this._action) { + throw new Error("Missing action"); + } + + return await prisma.auditLog.create({ + data: { + action: this._action, + userId: this._userId || null, + reason: this._reason, + comment: this._comment, + + banId: this._ban?.id, + }, + }); + } +} diff --git a/packages/server/src/models/Instance.ts b/packages/server/src/models/Instance.ts index 0a1c3b8..0b8ad5c 100644 --- a/packages/server/src/models/Instance.ts +++ b/packages/server/src/models/Instance.ts @@ -14,6 +14,10 @@ export class Instance { this.instance = data; } + get hostname() { + return this.instance.hostname; + } + /** * Update Instance instance * @@ -91,10 +95,10 @@ export class Instance { publicNote: string | null | undefined, privateNote: string | null | undefined ) { - const subdomains = await Instance.getRegisteredSubdomains( + /*const subdomains = await Instance.getRegisteredSubdomains( this.instance.hostname ); - const existing = await this.getBan(); + const existing = await this.getBan();*/ const ban = await prisma.ban.upsert({ where: { instanceId: this.instance.id, @@ -112,6 +116,8 @@ export class Instance { privateNote, }, }); + + return ban; } /** @@ -126,11 +132,11 @@ export class Instance { if (!existing) throw new InstanceNotBanned(); - await prisma.ban.delete({ - where: { - id: existing.id, - }, + const ban = await prisma.ban.delete({ + where: { id: existing.id }, }); + + return ban; } static async fromDomain(hostname: string): Promise { diff --git a/packages/server/src/models/User.ts b/packages/server/src/models/User.ts index cd56a12..17be607 100644 --- a/packages/server/src/models/User.ts +++ b/packages/server/src/models/User.ts @@ -9,6 +9,7 @@ import { } from "@sc07-canvas/lib/src/net"; import { Ban, User as UserDB } from "@prisma/client"; import { Instance } from "./Instance"; +import { ConditionalPromise } from "../lib/utils"; const Logger = getLogger(); @@ -40,7 +41,7 @@ export class User { pixelStack: number; authSession?: AuthSession; undoExpires?: Date; - ban?: IUserBan; + private _ban?: IUserBan; isAdmin: boolean; isModerator: boolean; @@ -90,7 +91,7 @@ export class User { private async updateBanFromUserData(userData: UserDB & { Ban: Ban | null }) { if (userData.Ban) { - this.ban = { + this._ban = { id: userData.Ban.id, expires: userData.Ban.expiresAt, publicNote: userData.Ban.publicNote, @@ -173,12 +174,14 @@ export class User { * Sends packet to all user's sockets with current standing information */ updateStanding() { - if (this.ban) { + const ban = this.getBan(); + + if (ban) { for (const socket of this.sockets) { socket.emit("standing", { banned: true, - until: this.ban.expires.toISOString(), - reason: this.ban.publicNote || undefined, + until: ban.expires.toISOString(), + reason: ban.publicNote || undefined, }); } } else { @@ -188,6 +191,83 @@ export class User { } } + getBan( + update: DoUpdate = false as DoUpdate + ): ConditionalPromise { + if (update) { + return new Promise(async (res) => { + const user = await prisma.user.findFirst({ + where: { + sub: this.sub, + }, + include: { + Ban: true, + }, + }); + + if (!user?.Ban) { + return res(undefined); + } + + this._ban = { + type: "user", + id: user.Ban.id, + expires: user.Ban.expiresAt, + publicNote: user.Ban.publicNote, + }; + + res(this._ban); + }) as any; + } else { + return this._ban as any; + } + } + + async ban( + expires: Date, + publicNote: string | null | undefined, + privateNote: string | null | undefined + ) { + const ban = await prisma.ban.upsert({ + where: { + userId: this.sub, + }, + create: { + userId: this.sub, + expiresAt: expires, + publicNote, + privateNote, + }, + update: { + userId: this.sub, + expiresAt: expires, + publicNote, + privateNote, + }, + }); + + this._ban = { + id: ban.id, + type: "user", + expires, + publicNote: publicNote || null, + }; + + return ban; + } + + async unban() { + const existing = await this.getBan(true); + + if (!existing) throw new UserNotBanned(); + + const ban = await prisma.ban.delete({ + where: { id: existing.id }, + }); + + return ban; + } + /** * Notifies all sockets for this user of a message * @param alert @@ -249,3 +329,10 @@ export class UserNotFound extends Error { this.name = "UserNotFound"; } } + +export class UserNotBanned extends Error { + constructor() { + super(); + this.name = "UserNotBanned"; + } +}