feat(admin): enforce 3-tier permission matrix, stamp role+IP on every log entry

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.
This commit is contained in:
admin committed 2026-08-12 08:53:32 +02:00
1 parent 2d656bd779
commit c94e60ab82
3 files changed
+75 -32

No files matched your search

+6
View File
@@ -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)])
+66 -29
View File
@@ -23,20 +23,33 @@ async function isAdmin(prisma: PrismaClient, userId: string): Promise<boolean> {
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, number> = {
[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<ResolvedAdminRole> {
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({
+3 -3
View File
@@ -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,