From c94e60ab822f5d945acca1aa635bafd4cbf36bbc Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Wed, 12 Aug 2026 08:53:32 +0200 Subject: [PATCH] feat(admin): enforce 3-tier permission matrix, stamp role+IP on every log entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea #10 follow-up: - assertAdmin now takes a minRole (SUPPORT < MODERATOR < SUPER_ADMIN) instead of a boolean requireSuperAdmin flag, and returns the caller's resolved role so mutation sites can log it. Applied per the agreed matrix: read-only/support tasks stay SUPPORT, destructive content/user actions require MODERATOR, ads/roles/invites/legal-doc-publishing require SUPER_ADMIN. - ModerationLog gains moderatorRole and ipAddress (both nullable — historical rows predate capture), populated on every write for login-abuse forensics. --- prisma/schema.prisma | 6 +++ src/trpc/routers/admin.ts | 95 +++++++++++++++++++++++++++------------ src/trpc/routers/legal.ts | 6 +-- 3 files changed, 75 insertions(+), 32 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6587f14..cc3ba05 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -642,6 +642,12 @@ model ModerationLog { targetId String reason String? @db.VarChar(500) moderatorId String // ownerId + // Both nullable: added after the table already had rows, so historical + // entries predate capture. Every new write always populates both — role is + // "BOOTSTRAP" for the env-configured ADMIN_OWNER_ID (no AdminRole row of + // its own), otherwise the AdminRoleType held at the time of the action. + moderatorRole String? @db.VarChar(20) + ipAddress String? @db.VarChar(45) // max IPv6 length; login-abuse / audit trail createdAt DateTime @default(now()) @@index([moderatorId, createdAt(sort: Desc)]) diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index beebffa..489af76 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -23,20 +23,33 @@ async function isAdmin(prisma: PrismaClient, userId: string): Promise { return role !== null; } +/** A caller's resolved admin identity for logging — "BOOTSTRAP" has no AdminRole row of its own. */ +export type ResolvedAdminRole = AdminRoleType | "BOOTSTRAP"; + +const ROLE_RANK: Record = { + [AdminRoleType.SUPPORT]: 0, + [AdminRoleType.MODERATOR]: 1, + [AdminRoleType.SUPER_ADMIN]: 2, +}; + +/** + * Resolves the caller's admin role, throwing FORBIDDEN unless it meets + * `minRole` on the SUPPORT < MODERATOR < SUPER_ADMIN scale (permission + * matrix, Gitea #10). Returns the resolved role so mutation call sites can + * stamp it onto the ModerationLog entry they write in the same request. + */ export async function assertAdmin( prisma: PrismaClient, userId: string, - requireSuperAdmin = false -) { - const isSuperOwner = process.env.ADMIN_OWNER_ID === userId; - if (isSuperOwner && !requireSuperAdmin) return; - if (isSuperOwner) return; + minRole: AdminRoleType = AdminRoleType.SUPPORT +): Promise { + if (process.env.ADMIN_OWNER_ID === userId) return "BOOTSTRAP"; const role = await prisma.adminRole.findUnique({ where: { ownerId: userId } }); - if (!role) throw new TRPCError({ code: "FORBIDDEN" }); - if (requireSuperAdmin && role.role !== AdminRoleType.SUPER_ADMIN) { + if (!role || ROLE_RANK[role.role] < ROLE_RANK[minRole]) { throw new TRPCError({ code: "FORBIDDEN" }); } + return role.role; } type Granularity = "day" | "week" | "month"; @@ -218,7 +231,7 @@ export const adminRouter = router({ deletePost: protectedProcedure .input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); // If this post is itself a repost, deleting it must decrement the // original post's repostCount — the same denormalized counter @@ -246,6 +259,8 @@ export const adminRouter = router({ targetId: input.postId, reason: input.reason ?? null, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }), ]); @@ -266,7 +281,7 @@ export const adminRouter = router({ deleteComment: protectedProcedure .input(z.object({ commentId: z.string().min(1), reason: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); await ctx.prisma.$transaction(async (tx) => { const deleted = await tx.comment.delete({ where: { id: input.commentId } }); await tx.post.update({ @@ -280,6 +295,8 @@ export const adminRouter = router({ targetId: input.commentId, reason: input.reason ?? null, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }); }); @@ -379,7 +396,7 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); if (input.ownerId === ctx.userId) throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot ban yourself." }); await ctx.prisma.$transaction([ @@ -405,6 +422,8 @@ export const adminRouter = router({ targetId: input.ownerId, reason: input.reason ?? null, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }), ]); @@ -414,7 +433,7 @@ export const adminRouter = router({ unbanUser: protectedProcedure .input(z.object({ ownerId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); await ctx.prisma.$transaction([ ctx.prisma.userBan.delete({ where: { ownerId: input.ownerId } }), ctx.prisma.moderationLog.create({ @@ -424,6 +443,8 @@ export const adminRouter = router({ targetId: input.ownerId, reason: null, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }), ]); @@ -519,7 +540,7 @@ export const adminRouter = router({ exportUserData: protectedProcedure .input(z.object({ ownerId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId); const exportData = await buildDataExport(ctx.prisma, input.ownerId); if (!exportData) throw new TRPCError({ code: "NOT_FOUND" }); @@ -558,6 +579,8 @@ export const adminRouter = router({ targetId: input.ownerId, reason: null, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }); @@ -612,7 +635,7 @@ export const adminRouter = router({ dismissReport: protectedProcedure .input(z.object({ reportId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); const report = await ctx.prisma.report.findUnique({ where: { id: input.reportId } }); if (!report) throw new TRPCError({ code: "NOT_FOUND" }); await ctx.prisma.$transaction([ @@ -623,6 +646,8 @@ export const adminRouter = router({ targetType: "REPORT", targetId: input.reportId, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }), ]); @@ -632,7 +657,7 @@ export const adminRouter = router({ deleteReportedPost: protectedProcedure .input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); // Same repostCount fix as deletePost above. const post = await ctx.prisma.post.findUnique({ @@ -659,6 +684,8 @@ export const adminRouter = router({ targetId: input.postId, reason: input.reason ?? null, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }), ]); @@ -674,7 +701,7 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); const ext = input.contentType.split("/")[1]!.replace("jpeg", "jpg"); const key = `ads/${ctx.userId}/${randomUUID()}.${ext}`; const { signedUrl } = await createSignedUploadUrl(key); @@ -683,7 +710,7 @@ export const adminRouter = router({ // ─── Advertisements ────────────────────────────────────────────────────────── listAds: protectedProcedure.query(async ({ ctx }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); return ctx.prisma.advertisement.findMany({ orderBy: { createdAt: "desc" } }); }), @@ -702,7 +729,7 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); // Interactive transaction (not the array form used elsewhere in this file) // because the log entry needs the DB-generated ad id, which only exists // after the create resolves. @@ -728,6 +755,8 @@ export const adminRouter = router({ targetId: ad.id, reason: input.title, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }); return ad; @@ -750,7 +779,7 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); const { id, targetUrl, startDate, endDate, ...rest } = input; const [ad] = await ctx.prisma.$transaction([ ctx.prisma.advertisement.update({ @@ -768,6 +797,8 @@ export const adminRouter = router({ targetType: "AD", targetId: id, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }), ]); @@ -777,7 +808,7 @@ export const adminRouter = router({ deleteAd: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); await ctx.prisma.$transaction([ ctx.prisma.advertisement.delete({ where: { id: input.id } }), ctx.prisma.moderationLog.create({ @@ -786,6 +817,8 @@ export const adminRouter = router({ targetType: "AD", targetId: input.id, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }), ]); @@ -824,9 +857,9 @@ export const adminRouter = router({ }); }), - // ─── Moderator management (super-admin only) ───────────────────────────────── + // ─── Moderator management (grant/revoke stay super-admin only) ─────────────── listModerators: protectedProcedure.query(async ({ ctx }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); return ctx.prisma.adminRole.findMany({ include: { owner: { @@ -840,7 +873,7 @@ export const adminRouter = router({ grantRole: protectedProcedure .input(z.object({ ownerId: z.string().min(1), role: z.nativeEnum(AdminRoleType) })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId, true); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); const [role] = await ctx.prisma.$transaction([ ctx.prisma.adminRole.upsert({ where: { ownerId: input.ownerId }, @@ -854,6 +887,8 @@ export const adminRouter = router({ targetId: input.ownerId, reason: input.role, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }), ]); @@ -863,7 +898,7 @@ export const adminRouter = router({ revokeRole: protectedProcedure .input(z.object({ ownerId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId, true); + const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); await ctx.prisma.$transaction([ ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } }), ctx.prisma.moderationLog.create({ @@ -872,6 +907,8 @@ export const adminRouter = router({ targetType: "USER", targetId: input.ownerId, moderatorId: ctx.userId, + moderatorRole: callerRole, + ipAddress: ctx.ip, }, }), ]); @@ -881,7 +918,7 @@ export const adminRouter = router({ getModerationLog: protectedProcedure .input(z.object({ limit: z.number().min(1).max(100).default(50) })) .query(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); return ctx.prisma.moderationLog.findMany({ orderBy: { createdAt: "desc" }, take: input.limit, @@ -892,7 +929,7 @@ export const adminRouter = router({ getModeratorThroughput: protectedProcedure .input(z.object({ days: z.number().min(1).max(365).default(30) })) .query(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); const since = new Date(Date.now() - input.days * 24 * 60 * 60 * 1000); const grouped = await ctx.prisma.moderationLog.groupBy({ by: ["moderatorId"], @@ -908,7 +945,7 @@ export const adminRouter = router({ listInviteCodes: protectedProcedure .input(z.object({ limit: z.number().min(1).max(200).default(100) })) .query(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); return ctx.prisma.inviteCode.findMany({ orderBy: { createdAt: "desc" }, take: input.limit, @@ -919,7 +956,7 @@ export const adminRouter = router({ }), createRootInvite: protectedProcedure.mutation(async ({ ctx }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); const { generateInviteCode } = await import("@/lib/invite-codes"); return ctx.prisma.inviteCode.create({ data: { code: generateInviteCode(), issuedById: null }, @@ -929,7 +966,7 @@ export const adminRouter = router({ revokeInvite: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); await ctx.prisma.inviteCode.update({ where: { id: input.id }, data: { revoked: true }, @@ -938,7 +975,7 @@ export const adminRouter = router({ }), backfillInviteCodes: protectedProcedure.mutation(async ({ ctx }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); const { generateInviteCode, INVITE_CODES_PER_USER } = await import("@/lib/invite-codes"); const ownersWithoutCodes = await ctx.prisma.owner.findMany({ diff --git a/src/trpc/routers/legal.ts b/src/trpc/routers/legal.ts index efc17bd..ae50984 100644 --- a/src/trpc/routers/legal.ts +++ b/src/trpc/routers/legal.ts @@ -1,6 +1,6 @@ import "server-only"; import { z } from "zod"; -import { LegalDocType } from "@prisma/client"; +import { AdminRoleType, LegalDocType } from "@prisma/client"; import { router, protectedProcedure } from "@/trpc/init"; import { assertAdmin } from "@/trpc/routers/admin"; @@ -14,7 +14,7 @@ export const legalRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN); return ctx.prisma.legalDocumentVersion.create({ data: { docType: input.docType, @@ -25,7 +25,7 @@ export const legalRouter = router({ }), listVersions: protectedProcedure.query(async ({ ctx }) => { - await assertAdmin(ctx.prisma, ctx.userId); + await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR); return ctx.prisma.legalDocumentVersion.findMany({ orderBy: { publishedAt: "desc" }, take: 50,