diff --git a/src/core/RaidHubRoute.test.ts b/src/core/RaidHubRoute.test.ts index 1f5289c2..5004619d 100644 --- a/src/core/RaidHubRoute.test.ts +++ b/src/core/RaidHubRoute.test.ts @@ -1,5 +1,9 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test" +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { adminProtected } from "@/auth/admin" +import { generateJWT } from "@/auth/jwt" +import { attachUserAuth } from "@/auth/user-context" +import * as auditLog from "@/lib/audit/audit-log" import { errorHandler } from "@/middleware/error-handler" import { ErrorCode } from "@/schema/errors/ErrorCode" import { zBigIntString, zDigitString } from "@/schema/input" @@ -269,6 +273,106 @@ describe("raidhub route unhandled error", () => { }) }) +describe("raidhub route audit logging", () => { + test("mountable emits audit log when route has audit config", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const auditRoute = new RaidHubRoute({ + method: "post", + description: "audit test route", + isAdministratorRoute: true, + audit: { + action: "test.audit.action", + responseFields: ["ok"] + }, + handler: async () => + RaidHubRoute.ok({ + ok: true + }), + response: { + success: { + statusCode: 200, + schema: z.object({ + ok: z.boolean() + }) + } + } + }) + + const auditApp = express() + auditApp.use(express.json()) + auditApp.use(attachUserAuth) + auditApp.use(adminProtected) + auditApp.use("/audit-test", auditRoute.mountable) + + const token = generateJWT( + { + isAdmin: true, + bungieMembershipId: "4611686018555780000", + destinyMembershipIds: [] + }, + 600 + ) + + const res = await request(auditApp) + .post("/audit-test") + .set("Authorization", "Bearer " + token) + + expect(res.status).toBe(200) + expect(auditSpy).toHaveBeenCalledTimes(1) + expect(auditSpy.mock.calls[0][0].action).toBe("test.audit.action") + } finally { + auditSpy.mockRestore() + } + }) + + test("deprecatedCopy preserves audit config", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const route = new RaidHubRoute({ + method: "put", + description: "audit copy test", + isAdministratorRoute: true, + audit: { action: "test.audit.copy", responseFields: ["copied"] }, + handler: async () => RaidHubRoute.ok({ copied: true }), + response: { + success: { + statusCode: 200, + schema: z.object({ copied: z.boolean() }) + } + } + }) + + const auditApp = express() + auditApp.use(express.json()) + auditApp.use(attachUserAuth) + auditApp.use(adminProtected) + auditApp.use("/audit-copy", route.deprecatedCopy().mountable) + + const token = generateJWT( + { + isAdmin: true, + bungieMembershipId: "4611686018555780000", + destinyMembershipIds: [] + }, + 600 + ) + + await request(auditApp) + .put("/audit-copy") + .set("Authorization", "Bearer " + token) + + expect(auditSpy).toHaveBeenCalledTimes(1) + expect(auditSpy.mock.calls[0][0].action).toBe("test.audit.copy") + expect(auditSpy.mock.calls[0][0].response?.copied).toBe(true) + } finally { + auditSpy.mockRestore() + } + }) +}) + describe("test raidhub route openapi gen", () => { test("get schema", () => { const openapi = testGetRoute.$generateOpenApiRoutes()[0] diff --git a/src/core/RaidHubRoute.ts b/src/core/RaidHubRoute.ts index 25b05111..d5ff9bd6 100644 --- a/src/core/RaidHubRoute.ts +++ b/src/core/RaidHubRoute.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any */ import { authFromHeaders } from "@/auth/user-context" import { Logger } from "@/lib/utils/logging" +import { AuditRouteConfig, auditRoute } from "@/middleware/audit-log" import { durationMetrics } from "@/middleware/duration-metrics" import { regionMetrics } from "@/middleware/region-metrics" import { requestLogging } from "@/middleware/request-logging" @@ -59,6 +60,7 @@ export class RaidHubRoute< private readonly isAdministratorRoute: boolean = false private readonly isProtectedPlayerRoute: boolean = false private readonly isDeprecated: boolean = false + private readonly auditConfig: AuditRouteConfig | null = null private readonly middlewares: RequestHandler< z.output, any, @@ -84,6 +86,8 @@ export class RaidHubRoute< isAdministratorRoute?: boolean isProtectedPlayerRoute?: boolean isDeprecated?: boolean + /** Emits structured ADMIN_ACTION audit logs for accountability */ + audit?: AuditRouteConfig middleware?: RequestHandler, any, z.output, z.output>[] handler: RaidHubHandler< Params, @@ -112,6 +116,7 @@ export class RaidHubRoute< this.isAdministratorRoute = args.isAdministratorRoute ?? false this.isProtectedPlayerRoute = args.isProtectedPlayerRoute ?? false this.isDeprecated = args.isDeprecated ?? false + this.auditConfig = args.audit ?? null this.middlewares = args.middleware ?? [] this.handler = args.handler this.responseSchema = args.response.success.schema @@ -216,6 +221,7 @@ export class RaidHubRoute< this.validateParams, this.validateQuery, this.validateBody, + ...(this.auditConfig ? [auditRoute(this.auditConfig)] : []), ...this.middlewares, async (req, res, next) => { try { @@ -293,6 +299,7 @@ export class RaidHubRoute< body: this.bodySchema, isAdministratorRoute: this.isAdministratorRoute, isProtectedPlayerRoute: this.isProtectedPlayerRoute, + audit: this.auditConfig ?? undefined, middleware: this.middlewares, handler: this.handler, response: { diff --git a/src/lib/audit/audit-log.test.ts b/src/lib/audit/audit-log.test.ts new file mode 100644 index 00000000..d2b2780f --- /dev/null +++ b/src/lib/audit/audit-log.test.ts @@ -0,0 +1,67 @@ +import { Logger } from "@/lib/utils/logging" +import { describe, expect, spyOn, test } from "bun:test" + +import { writeAuditLog } from "./audit-log" + +describe("writeAuditLog", () => { + test("forwards full record to logger with stringified optional fields", () => { + const infoSpy = spyOn(Logger.prototype, "info").mockImplementation(() => {}) + + try { + writeAuditLog({ + action: "reporting.blacklist.update", + actorBungieMembershipId: "4611686018555780000", + method: "PUT", + route: "/admin/reporting/blacklist/123", + outcome: "success", + statusCode: 200, + params: { instanceId: "123" }, + request: { reason: "2 man" }, + response: { blacklisted: true }, + errorCode: "PlayerNotFoundError" + }) + + expect(infoSpy).toHaveBeenCalledTimes(1) + expect(infoSpy).toHaveBeenCalledWith("ADMIN_ACTION", { + action: "reporting.blacklist.update", + actor_bungie_membership_id: "4611686018555780000", + method: "PUT", + route: "/admin/reporting/blacklist/123", + outcome: "success", + status_code: 200, + params: JSON.stringify({ instanceId: "123" }), + request: JSON.stringify({ reason: "2 man" }), + response: JSON.stringify({ blacklisted: true }), + error_code: "PlayerNotFoundError" + }) + } finally { + infoSpy.mockRestore() + } + }) + + test("omits optional fields when absent", () => { + const infoSpy = spyOn(Logger.prototype, "info").mockImplementation(() => {}) + + try { + writeAuditLog({ + action: "admin.query.execute", + actorBungieMembershipId: "4611686018555780000", + method: "POST", + route: "/admin/query", + outcome: "failure", + statusCode: 403 + }) + + expect(infoSpy).toHaveBeenCalledWith("ADMIN_ACTION", { + action: "admin.query.execute", + actor_bungie_membership_id: "4611686018555780000", + method: "POST", + route: "/admin/query", + outcome: "failure", + status_code: 403 + }) + } finally { + infoSpy.mockRestore() + } + }) +}) diff --git a/src/lib/audit/audit-log.ts b/src/lib/audit/audit-log.ts new file mode 100644 index 00000000..37cc0a1f --- /dev/null +++ b/src/lib/audit/audit-log.ts @@ -0,0 +1,33 @@ +import { Logger } from "@/lib/utils/logging" + +const logger = new Logger("AUDIT") + +export type AuditOutcome = "success" | "failure" + +export type AuditRecord = { + action: string + actorBungieMembershipId: string + method: string + route: string + outcome: AuditOutcome + statusCode: number + params?: Record + request?: Record + response?: Record + errorCode?: string +} + +export const writeAuditLog = (record: AuditRecord): void => { + logger.info("ADMIN_ACTION", { + action: record.action, + actor_bungie_membership_id: record.actorBungieMembershipId, + method: record.method, + route: record.route, + outcome: record.outcome, + status_code: record.statusCode, + ...(record.params ? { params: JSON.stringify(record.params) } : {}), + ...(record.request ? { request: JSON.stringify(record.request) } : {}), + ...(record.response ? { response: JSON.stringify(record.response) } : {}), + ...(record.errorCode ? { error_code: record.errorCode } : {}) + }) +} diff --git a/src/lib/audit/sanitize.test.ts b/src/lib/audit/sanitize.test.ts new file mode 100644 index 00000000..5d190268 --- /dev/null +++ b/src/lib/audit/sanitize.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" + +import { sanitizeForAudit } from "./sanitize" + +describe("sanitizeForAudit", () => { + test("redacts sensitive keys", () => { + expect( + sanitizeForAudit({ + reason: "cheaters", + password: "hunter2", + nested: { apiKey: "secret-value" } + }) + ).toEqual({ + reason: "cheaters", + password: "[REDACTED]", + nested: { apiKey: "[REDACTED]" } + }) + }) + + test("truncates long strings", () => { + const long = "x".repeat(9000) + const sanitized = sanitizeForAudit(long, { maxStringLength: 100 }) as string + expect(sanitized.startsWith("x".repeat(100))).toBe(true) + expect(sanitized).toContain("[truncated") + }) + + test("passes through null and undefined", () => { + expect(sanitizeForAudit(null)).toBe(null) + expect(sanitizeForAudit(undefined)).toBe(undefined) + }) + + test("passes through numbers and booleans", () => { + expect(sanitizeForAudit(42)).toBe(42) + expect(sanitizeForAudit(false)).toBe(false) + }) + + test("stringifies bigint values", () => { + expect(sanitizeForAudit(16897747714n)).toBe("16897747714") + }) + + test("sanitizes arrays recursively", () => { + expect( + sanitizeForAudit([{ token: "secret" }, "visible", [1, { api_key: "hidden" }]]) + ).toEqual([{ token: "[REDACTED]" }, "visible", [1, { api_key: "[REDACTED]" }]]) + }) + + test("redacts custom keys", () => { + expect( + sanitizeForAudit({ query: "SELECT 1", note: "safe" }, { redactKeys: ["query"] }) + ).toEqual({ query: "[REDACTED]", note: "safe" }) + }) + + test("truncates at max depth", () => { + let nested: Record = { value: "leaf" } + for (let i = 0; i < 10; i++) { + nested = { nested } + } + + const sanitized = sanitizeForAudit(nested) as Record + let current: unknown = sanitized + for (let i = 0; i < 8; i++) { + current = (current as Record).nested + } + expect(current).toEqual({ nested: "[Truncated: max depth]" }) + }) + + test("stringifies non-plain values", () => { + expect(sanitizeForAudit(() => "noop")).toBe('() => "noop"') + expect(sanitizeForAudit(Symbol("tag"))).toBe("Symbol(tag)") + }) +}) diff --git a/src/lib/audit/sanitize.ts b/src/lib/audit/sanitize.ts new file mode 100644 index 00000000..5ad39583 --- /dev/null +++ b/src/lib/audit/sanitize.ts @@ -0,0 +1,74 @@ +const DEFAULT_REDACT_KEYS = new Set([ + "password", + "token", + "secret", + "authorization", + "apikey", + "api_key" +]) + +const DEFAULT_MAX_STRING_LENGTH = 8_192 + +const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +export type SanitizeForAuditOptions = { + redactKeys?: readonly string[] + maxStringLength?: number +} + +export const sanitizeForAudit = ( + value: unknown, + options: SanitizeForAuditOptions = {} +): unknown => { + const redactKeys = new Set([ + ...DEFAULT_REDACT_KEYS, + ...(options.redactKeys ?? []).map(k => k.toLowerCase()) + ]) + const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH + + const sanitize = (current: unknown, depth: number): unknown => { + if (depth > 8) { + return "[Truncated: max depth]" + } + + if (current === null || current === undefined) { + return current + } + + if (typeof current === "string") { + if (current.length <= maxStringLength) { + return current + } + return `${current.slice(0, maxStringLength)}…[truncated ${current.length - maxStringLength} chars]` + } + + if (typeof current === "number" || typeof current === "boolean") { + return current + } + + if (typeof current === "bigint") { + return current.toString() + } + + if (Array.isArray(current)) { + return current.map(item => sanitize(item, depth + 1)) + } + + if (!isPlainObject(current)) { + return String(current) + } + + const sanitized: Record = {} + for (const [key, nested] of Object.entries(current)) { + if (redactKeys.has(key.toLowerCase())) { + sanitized[key] = "[REDACTED]" + continue + } + sanitized[key] = sanitize(nested, depth + 1) + } + return sanitized + } + + return sanitize(value, 0) +} diff --git a/src/middleware/audit-log.test.ts b/src/middleware/audit-log.test.ts new file mode 100644 index 00000000..96787eaa --- /dev/null +++ b/src/middleware/audit-log.test.ts @@ -0,0 +1,266 @@ +import { adminProtected } from "@/auth/admin" +import { generateJWT } from "@/auth/jwt" +import { attachUserAuth } from "@/auth/user-context" +import * as auditLog from "@/lib/audit/audit-log" +import { describe, expect, spyOn, test } from "bun:test" +import express from "express" +import request from "supertest" + +import { auditRoute } from "./audit-log" + +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret" + +describe("auditRoute middleware", () => { + const adminToken = () => + generateJWT( + { + isAdmin: true, + bungieMembershipId: "4611686018555780000", + destinyMembershipIds: [] + }, + 600 + ) + + const buildAuthedApp = ( + path: string, + config: Parameters[0], + handler: express.RequestHandler + ) => { + const app = express() + app.use(express.json()) + app.use(attachUserAuth) + app.use(adminProtected) + app.use(path, auditRoute(config), handler) + return app + } + + test("logs successful admin mutation with actor, params, and request body", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/reporting/blacklist/:instanceId", + { + action: "reporting.blacklist.update", + responseFields: ["blacklisted"] + }, + (req, res) => { + res.status(200).json({ + minted: new Date(), + success: true, + response: { blacklisted: true } + }) + } + ) + + const res = await request(app) + .put("/admin/reporting/blacklist/16897747714") + .set("Authorization", "Bearer " + adminToken()) + .send({ + reason: "2 man", + removeBlacklist: false + }) + + expect(res.status).toBe(200) + expect(auditSpy).toHaveBeenCalledTimes(1) + + const record = auditSpy.mock.calls[0][0] + expect(record.action).toBe("reporting.blacklist.update") + expect(record.actorBungieMembershipId).toBe("4611686018555780000") + expect(record.outcome).toBe("success") + expect(record.statusCode).toBe(200) + expect(record.params?.instanceId).toBe("16897747714") + expect(record.request?.reason).toBe("2 man") + expect(record.response?.blacklisted).toBe(true) + expect(record.errorCode).toBeUndefined() + } finally { + auditSpy.mockRestore() + } + }) + + test("logs failure outcome and error code", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/reporting/player/:membershipId", + { + action: "reporting.player.update", + responseFields: ["updated"] + }, + (_req, res) => { + res.status(404).json({ + minted: new Date(), + success: false, + code: "PlayerNotFoundError", + error: { playerId: "999" } + }) + } + ) + + await request(app) + .patch("/admin/reporting/player/999") + .set("Authorization", "Bearer " + adminToken()) + .send({ flagged: true }) + + expect(auditSpy).toHaveBeenCalledTimes(1) + const record = auditSpy.mock.calls[0][0] + expect(record.outcome).toBe("failure") + expect(record.statusCode).toBe(404) + expect(record.errorCode).toBe("PlayerNotFoundError") + expect(record.response?.success).toBe(false) + expect(record.response?.code).toBe("PlayerNotFoundError") + } finally { + auditSpy.mockRestore() + } + }) + + test("strips query string from route", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/query", + { action: "admin.query.execute" }, + (_req, res) => { + res.status(200).json({ success: true, response: { rows: [] } }) + } + ) + + await request(app) + .post("/admin/query?debug=1") + .set("Authorization", "Bearer " + adminToken()) + .send({ query: "SELECT 1", type: "readonly" }) + + const record = auditSpy.mock.calls[0][0] + expect(record.route).toBe("/admin/query") + } finally { + auditSpy.mockRestore() + } + }) + + test("respects includeParams and includeBody flags", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/reporting/blacklist/:instanceId", + { + action: "reporting.blacklist.update", + includeParams: false, + includeBody: false + }, + (_req, res) => { + res.status(200).json({ success: true }) + } + ) + + await request(app) + .put("/admin/reporting/blacklist/16897747714") + .set("Authorization", "Bearer " + adminToken()) + .send({ reason: "secret reason" }) + + const record = auditSpy.mock.calls[0][0] + expect(record.params).toBeUndefined() + expect(record.request).toBeUndefined() + } finally { + auditSpy.mockRestore() + } + }) + + test("applies custom redactBodyKeys", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/query", + { + action: "admin.query.execute", + redactBodyKeys: ["query"] + }, + (_req, res) => { + res.status(200).json({ success: true }) + } + ) + + await request(app) + .post("/admin/query") + .set("Authorization", "Bearer " + adminToken()) + .send({ query: "SELECT * FROM player", type: "readonly" }) + + const record = auditSpy.mock.calls[0][0] + expect(record.request?.query).toBe("[REDACTED]") + expect(record.request?.type).toBe("readonly") + } finally { + auditSpy.mockRestore() + } + }) + + test("picks response fields from unwrapped body", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/custom", + { + action: "custom.action", + responseFields: ["value"] + }, + (_req, res) => { + res.status(200).json({ value: 42, extra: "ignored" }) + } + ) + + await request(app) + .post("/admin/custom") + .set("Authorization", "Bearer " + adminToken()) + .send({}) + + const record = auditSpy.mock.calls[0][0] + expect(record.response).toEqual({ value: 42 }) + } finally { + auditSpy.mockRestore() + } + }) + + test("omits response when body is not an object", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = buildAuthedApp( + "/admin/custom", + { action: "custom.action", responseFields: ["value"] }, + (_req, res) => { + res.status(204).end() + } + ) + + await request(app) + .post("/admin/custom") + .set("Authorization", "Bearer " + adminToken()) + + const record = auditSpy.mock.calls[0][0] + expect(record.response).toBeUndefined() + } finally { + auditSpy.mockRestore() + } + }) + + test("does not log when actor is missing", async () => { + const auditSpy = spyOn(auditLog, "writeAuditLog").mockImplementation(() => {}) + + try { + const app = express() + app.use(express.json()) + app.post("/admin/query", auditRoute({ action: "admin.query.execute" }), (_req, res) => { + res.status(200).json({ success: true }) + }) + + await request(app).post("/admin/query").send({ query: "SELECT 1" }) + + expect(auditSpy).not.toHaveBeenCalled() + } finally { + auditSpy.mockRestore() + } + }) +}) diff --git a/src/middleware/audit-log.ts b/src/middleware/audit-log.ts new file mode 100644 index 00000000..2627837b --- /dev/null +++ b/src/middleware/audit-log.ts @@ -0,0 +1,118 @@ +import { writeAuditLog, type AuditOutcome } from "@/lib/audit/audit-log" +import { sanitizeForAudit } from "@/lib/audit/sanitize" +import { RequestHandler } from "express" +import type { ParamsDictionary, Query } from "express-serve-static-core" +import { RaidHubLocals } from "./types" + +export type AuditRouteConfig = { + /** Stable identifier, e.g. reporting.blacklist.update */ + action: string + includeParams?: boolean + includeBody?: boolean + redactBodyKeys?: string[] + /** Response JSON keys to include when present (e.g. blacklisted) */ + responseFields?: string[] +} + +const pickResponseFields = ( + body: unknown, + fields: string[] | undefined +): Record | undefined => { + if (typeof body !== "object" || body === null) { + return undefined + } + + const source = body as Record + const payload = + source.response && typeof source.response === "object" + ? (source.response as Record) + : source + + const picked: Record = {} + + if (fields?.length) { + for (const field of fields) { + if (field in payload) { + picked[field] = payload[field] + } + } + } + + if ("success" in source) { + picked.success = source.success + } + if (source.success === false && typeof source.code === "string") { + picked.code = source.code + } + + return Object.keys(picked).length > 0 ? picked : undefined +} + +export const auditRoute = < + P extends ParamsDictionary = ParamsDictionary, + ResBody = unknown, + ReqBody = unknown, + ReqQuery extends Query = Query +>( + config: AuditRouteConfig +): RequestHandler => { + const includeParams = config.includeParams ?? true + const includeBody = config.includeBody ?? true + + return (req, res, next) => { + const originalJson = res.json.bind(res) + + res.json = ((body?: ResBody) => { + res.locals._auditResponseBody = body + return originalJson(body as ResBody) + }) as typeof res.json + + res.once("finish", () => { + const actor = req.auth?.bungieMembershipId + if (!actor) { + return + } + + const statusCode = res.statusCode + const outcome: AuditOutcome = statusCode >= 400 ? "failure" : "success" + const responseBody = res.locals._auditResponseBody + + let errorCode: string | undefined + if ( + typeof responseBody === "object" && + responseBody !== null && + "success" in responseBody + ) { + const failedResponse = responseBody as { success?: boolean; code?: unknown } + if (failedResponse.success === false && typeof failedResponse.code === "string") { + errorCode = failedResponse.code + } + } + + writeAuditLog({ + action: config.action, + actorBungieMembershipId: actor, + method: req.method, + route: req.originalUrl.split("?")[0] ?? req.path, + outcome, + statusCode, + errorCode, + ...(includeParams + ? { + params: sanitizeForAudit(req.params) as Record + } + : {}), + ...(includeBody && req.body != null + ? { + request: sanitizeForAudit(req.body, { + redactKeys: config.redactBodyKeys + }) as Record + } + : {}), + response: pickResponseFields(responseBody, config.responseFields) + }) + }) + + next() + } +} diff --git a/src/middleware/types.ts b/src/middleware/types.ts index e2a63fe1..18cd6a92 100644 --- a/src/middleware/types.ts +++ b/src/middleware/types.ts @@ -6,4 +6,6 @@ export interface RaidHubLocals { // populated by duration-metrics _startTime: number _duration: number + // populated by audit-log middleware + _auditResponseBody?: unknown } diff --git a/src/routes/admin/query.ts b/src/routes/admin/query.ts index 5ee693d0..587af755 100644 --- a/src/routes/admin/query.ts +++ b/src/routes/admin/query.ts @@ -10,6 +10,10 @@ export const adminQueryRoute = new RaidHubRoute({ isAdministratorRoute: true, description: "Run a query against the database", method: "post", + audit: { + action: "admin.query.execute", + responseFields: ["type", "cost"] + }, body: z.object({ query: z.string(), type: z.enum(["SELECT", "EXPLAIN"]), diff --git a/src/routes/admin/reporting/blacklist.ts b/src/routes/admin/reporting/blacklist.ts index 1841aa4b..7271d764 100644 --- a/src/routes/admin/reporting/blacklist.ts +++ b/src/routes/admin/reporting/blacklist.ts @@ -10,6 +10,10 @@ export const blacklistInstanceRoute = new RaidHubRoute({ isAdministratorRoute: true, method: "put", description: "Blacklist an instance from leaderboards, as well as the players involved.", + audit: { + action: "reporting.blacklist.update", + responseFields: ["blacklisted"] + }, params: z.object({ instanceId: zBigIntString() }), diff --git a/src/routes/admin/reporting/player.ts b/src/routes/admin/reporting/player.ts index aaf40754..fd9c4134 100644 --- a/src/routes/admin/reporting/player.ts +++ b/src/routes/admin/reporting/player.ts @@ -11,6 +11,9 @@ export const patchPlayer = new RaidHubRoute({ isAdministratorRoute: true, method: "patch", description: "Update fields on a player. Currently, only the cheat level can be updated.", + audit: { + action: "reporting.player.update" + }, params: z.object({ membershipId: zBigIntString() }),