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:
1 parent
2d656bd779
commit
c94e60ab82
3 files changed
+75
-32
No files matched your search
@@ -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
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user