Audit Log

This commit is contained in:
Grant 2024-07-09 03:50:06 +00:00
parent 5f57679bb3
commit caf19b247d
13 changed files with 565 additions and 63 deletions

View File

@ -9,6 +9,7 @@ import {
faCog, faCog,
faHashtag, faHashtag,
faHome, faHome,
faList,
faServer, faServer,
faShieldHalved, faShieldHalved,
faSquare, faSquare,
@ -54,6 +55,12 @@ export const SidebarWrapper = () => {
isActive={pathname === "/"} isActive={pathname === "/"}
href="/" href="/"
/> />
<SidebarItem
title="Audit Log"
icon={<FontAwesomeIcon icon={faList} />}
isActive={pathname === "/audit"}
href="/audit"
/>
<CollapseItems <CollapseItems
icon={<FontAwesomeIcon icon={faChartBar} />} icon={<FontAwesomeIcon icon={faChartBar} />}
title="Stats" title="Stats"

View File

@ -9,6 +9,7 @@ import { HomePage } from "./pages/Home/page.tsx";
import { AccountsPage } from "./pages/Accounts/Accounts/page.tsx"; import { AccountsPage } from "./pages/Accounts/Accounts/page.tsx";
import { ServiceSettingsPage } from "./pages/Service/settings.tsx"; import { ServiceSettingsPage } from "./pages/Service/settings.tsx";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { AuditLog } from "./pages/AuditLog/auditlog.tsx";
const router = createBrowserRouter( const router = createBrowserRouter(
[ [
@ -28,6 +29,10 @@ const router = createBrowserRouter(
path: "/service/settings", path: "/service/settings",
element: <ServiceSettingsPage />, element: <ServiceSettingsPage />,
}, },
{
path: "/audit",
element: <AuditLog />,
},
], ],
}, },
], ],

View File

@ -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<AuditLog[]>([]);
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 (
<>
<h4 className="text-l font-semibold">Audit Log</h4>
<div className="relative">
<Table>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>User ID</TableColumn>
<TableColumn>Action</TableColumn>
<TableColumn>Reason</TableColumn>
<TableColumn>Comment</TableColumn>
<TableColumn>Created At / Updated At</TableColumn>
</TableHeader>
<TableBody>
{auditLogs.map((log) => (
<TableRow key={log.id}>
<TableCell>{log.id}</TableCell>
<TableCell>{log.userId}</TableCell>
<TableCell>{log.action}</TableCell>
<TableCell>{log.reason}</TableCell>
<TableCell>{log.comment}</TableCell>
<TableCell>
{log.createdAt} / {log.updatedAt}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
);
};

View File

@ -20,6 +20,7 @@ Table User {
pixels Pixel [not null] pixels Pixel [not null]
FactionMember FactionMember [not null] FactionMember FactionMember [not null]
Ban Ban Ban Ban
AuditLog AuditLog [not null]
} }
Table Instance { Table Instance {
@ -109,8 +110,30 @@ Table Ban {
privateNote String privateNote String
publicNote String publicNote String
expiresAt DateTime [not null] expiresAt DateTime [not null]
createdAt DateTime [default: `now()`, not null]
updatedAt DateTime
user User user User
instance Instance 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 Ref: Pixel.userId > User.sub
@ -130,3 +153,7 @@ Ref: FactionSetting.factionId > Faction.id
Ref: Ban.userId - User.sub Ref: Ban.userId - User.sub
Ref: Ban.instanceId - Instance.id Ref: Ban.instanceId - Instance.id
Ref: AuditLog.userId > User.sub
Ref: AuditLog.banId > Ban.id

View File

@ -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;

View File

@ -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);

View File

@ -35,6 +35,7 @@ model User {
pixels Pixel[] pixels Pixel[]
FactionMember FactionMember[] FactionMember FactionMember[]
Ban Ban? Ban Ban?
AuditLog AuditLog[]
} }
model Instance { model Instance {
@ -139,8 +140,32 @@ model Ban {
publicNote String? publicNote String?
expiresAt DateTime expiresAt DateTime
// TODO: link audit log createdAt DateTime @default(now())
updatedAt DateTime?
user User? @relation(fields: [userId], references: [sub]) user User? @relation(fields: [userId], references: [sub])
instance Instance? @relation(fields: [instanceId], references: [id]) 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])
} }

View File

@ -1,5 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { User, UserNotFound } from "../models/User"; import { User, UserNotBanned, UserNotFound } from "../models/User";
import Canvas from "../lib/Canvas"; import Canvas from "../lib/Canvas";
import { getLogger } from "../lib/Logger"; import { getLogger } from "../lib/Logger";
import { RateLimiter } from "../lib/RateLimiter"; import { RateLimiter } from "../lib/RateLimiter";
@ -10,6 +10,7 @@ import {
InstanceNotBanned, InstanceNotBanned,
InstanceNotFound, InstanceNotFound,
} from "../models/Instance"; } from "../models/Instance";
import { AuditLog } from "../models/AuditLog";
const app = Router(); const app = Router();
const Logger = getLogger("HTTP/ADMIN"); const Logger = getLogger("HTTP/ADMIN");
@ -206,6 +207,15 @@ app.put("/canvas/fill", async (req, res) => {
res.json({ success: true }); 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) => { app.put("/user/:sub/ban", async (req, res) => {
let user: User; let user: User;
let expires: Date; let expires: Date;
@ -270,23 +280,8 @@ app.put("/user/:sub/ban", async (req, res) => {
privateNote = req.body.privateNote; privateNote = req.body.privateNote;
} }
const existingBan = user.ban; const existingBan = user.getBan();
const ban = await user.ban(expires, publicNote, privateNote);
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);
let shouldNotifyUser = false; let shouldNotifyUser = false;
@ -312,11 +307,27 @@ app.put("/user/:sub/ban", async (req, res) => {
user.updateStanding(); 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) => { app.delete("/user/:sub/ban", async (req, res) => {
// delete ban ("unban") // delete ban ("unban")
@ -334,18 +345,20 @@ app.delete("/user/:sub/ban", async (req, res) => {
return; return;
} }
if (!user.ban?.id) { try {
res.status(400).json({ await user.unban();
success: false, } catch (e) {
error: "User is not banned", 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; return;
} }
await prisma.ban.delete({
where: { id: user.ban.id },
});
user.notify({ user.notify({
is: "modal", is: "modal",
action: "moderation", action: "moderation",
@ -357,9 +370,14 @@ app.delete("/user/:sub/ban", async (req, res) => {
await user.update(true); await user.update(true);
user.updateStanding(); 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) => { 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 }); 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) => { app.put("/instance/:domain/ban", async (req, res) => {
// ban domain & subdomains // ban domain & subdomains
@ -460,15 +487,34 @@ app.put("/instance/:domain/ban", async (req, res) => {
privateNote = req.body.privateNote; 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({ res.json({
success: true, success: true,
ban,
audit,
}); });
}); });
/**
* Delete an instance ban
*
* @header X-Audit
* @param :domain The instance domain
*/
app.delete("/instance/:domain/ban", async (req, res) => { app.delete("/instance/:domain/ban", async (req, res) => {
// unban domain & subdomains // unban domain & subdomains
@ -488,8 +534,9 @@ app.delete("/instance/:domain/ban", async (req, res) => {
return; return;
} }
let ban;
try { try {
await instance.unban(); ban = await instance.unban();
} catch (e) { } catch (e) {
if (e instanceof InstanceNotBanned) { if (e instanceof InstanceNotBanned) {
res.status(404).json({ success: false, error: "instance not banned" }); res.status(404).json({ success: false, error: "instance not banned" });
@ -502,9 +549,104 @@ app.delete("/instance/:domain/ban", async (req, res) => {
return; 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; export default app;

View File

@ -173,22 +173,23 @@ export class SocketServer {
if (socket.request.session.user) { if (socket.request.session.user) {
// inform the client of their session if it exists // inform the client of their session if it exists
socket.emit("user", socket.request.session.user); 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) { if (user) {
socket.emit("availablePixels", user.pixelStack); socket.emit("availablePixels", user.pixelStack);
socket.emit("pixelLastPlaced", user.lastPixelTime.getTime()); 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()); socket.emit("config", getClientConfig());
@ -261,7 +262,7 @@ export class SocketServer {
return; return;
} }
if (user.ban && user.ban.expires > new Date()) { if ((user.getBan()?.expires || 0) > new Date()) {
ack({ success: false, error: "banned" }); ack({ success: false, error: "banned" });
return; return;
} }

View File

@ -14,3 +14,8 @@ export const createEnum = <T extends string>(values: T[]): { [k in T]: k } => {
return ret; return ret;
}; };
export type ConditionalPromise<
T,
UsePromise extends boolean = false,
> = UsePromise extends true ? Promise<T> : UsePromise extends false ? T : never;

View File

@ -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,
},
});
}
}

View File

@ -14,6 +14,10 @@ export class Instance {
this.instance = data; this.instance = data;
} }
get hostname() {
return this.instance.hostname;
}
/** /**
* Update Instance instance * Update Instance instance
* *
@ -91,10 +95,10 @@ export class Instance {
publicNote: string | null | undefined, publicNote: string | null | undefined,
privateNote: string | null | undefined privateNote: string | null | undefined
) { ) {
const subdomains = await Instance.getRegisteredSubdomains( /*const subdomains = await Instance.getRegisteredSubdomains(
this.instance.hostname this.instance.hostname
); );
const existing = await this.getBan(); const existing = await this.getBan();*/
const ban = await prisma.ban.upsert({ const ban = await prisma.ban.upsert({
where: { where: {
instanceId: this.instance.id, instanceId: this.instance.id,
@ -112,6 +116,8 @@ export class Instance {
privateNote, privateNote,
}, },
}); });
return ban;
} }
/** /**
@ -126,11 +132,11 @@ export class Instance {
if (!existing) throw new InstanceNotBanned(); if (!existing) throw new InstanceNotBanned();
await prisma.ban.delete({ const ban = await prisma.ban.delete({
where: { where: { id: existing.id },
id: existing.id,
},
}); });
return ban;
} }
static async fromDomain(hostname: string): Promise<Instance> { static async fromDomain(hostname: string): Promise<Instance> {

View File

@ -9,6 +9,7 @@ import {
} from "@sc07-canvas/lib/src/net"; } from "@sc07-canvas/lib/src/net";
import { Ban, User as UserDB } from "@prisma/client"; import { Ban, User as UserDB } from "@prisma/client";
import { Instance } from "./Instance"; import { Instance } from "./Instance";
import { ConditionalPromise } from "../lib/utils";
const Logger = getLogger(); const Logger = getLogger();
@ -40,7 +41,7 @@ export class User {
pixelStack: number; pixelStack: number;
authSession?: AuthSession; authSession?: AuthSession;
undoExpires?: Date; undoExpires?: Date;
ban?: IUserBan; private _ban?: IUserBan;
isAdmin: boolean; isAdmin: boolean;
isModerator: boolean; isModerator: boolean;
@ -90,7 +91,7 @@ export class User {
private async updateBanFromUserData(userData: UserDB & { Ban: Ban | null }) { private async updateBanFromUserData(userData: UserDB & { Ban: Ban | null }) {
if (userData.Ban) { if (userData.Ban) {
this.ban = { this._ban = {
id: userData.Ban.id, id: userData.Ban.id,
expires: userData.Ban.expiresAt, expires: userData.Ban.expiresAt,
publicNote: userData.Ban.publicNote, publicNote: userData.Ban.publicNote,
@ -173,12 +174,14 @@ export class User {
* Sends packet to all user's sockets with current standing information * Sends packet to all user's sockets with current standing information
*/ */
updateStanding() { updateStanding() {
if (this.ban) { const ban = this.getBan();
if (ban) {
for (const socket of this.sockets) { for (const socket of this.sockets) {
socket.emit("standing", { socket.emit("standing", {
banned: true, banned: true,
until: this.ban.expires.toISOString(), until: ban.expires.toISOString(),
reason: this.ban.publicNote || undefined, reason: ban.publicNote || undefined,
}); });
} }
} else { } else {
@ -188,6 +191,83 @@ export class User {
} }
} }
getBan<DoUpdate extends boolean = false>(
update: DoUpdate = false as DoUpdate
): ConditionalPromise<typeof this._ban, DoUpdate> {
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 * Notifies all sockets for this user of a message
* @param alert * @param alert
@ -249,3 +329,10 @@ export class UserNotFound extends Error {
this.name = "UserNotFound"; this.name = "UserNotFound";
} }
} }
export class UserNotBanned extends Error {
constructor() {
super();
this.name = "UserNotBanned";
}
}