diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts index 8bca72137..0ddc89efe 100644 --- a/app/features/admin/admin-constants.ts +++ b/app/features/admin/admin-constants.ts @@ -3,6 +3,9 @@ export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274; // Panda Scep Acing Baja Michi export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094]; +// hfcRed +export const DEV_IDS = [27883]; + export const STAFF_DISCORD_IDS = [ "138757634500067328", "184478601171828737", diff --git a/app/features/admin/loaders/admin.server.ts b/app/features/admin/loaders/admin.server.ts index 65a7315d7..5b8c6dd22 100644 --- a/app/features/admin/loaders/admin.server.ts +++ b/app/features/admin/loaders/admin.server.ts @@ -1,14 +1,25 @@ import type { LoaderFunctionArgs } from "react-router"; -import { isImpersonating } from "~/features/auth/core/user.server"; +import { + getRealUserId, + isImpersonating, + requireUser, +} from "~/features/auth/core/user.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { requireRole } from "~/modules/permissions/guards.server"; +import { isAdmin, isDev, isStaff } from "~/modules/permissions/utils"; import { parseSafeSearchParams } from "~/utils/remix.server"; import { adminActionSearchParamsSchema } from "../admin-schemas"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls"; export const loader = async ({ request }: LoaderFunctionArgs) => { if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) { - requireRole("STAFF"); + const user = requireUser(); + const realUserId = await getRealUserId(request); + const userToCheck = + realUserId && realUserId !== user.id ? { id: realUserId } : user; + + if (!isAdmin(userToCheck) && !isStaff(userToCheck) && !isDev(userToCheck)) { + throw new Response("Forbidden", { status: 403 }); + } } const parsedSearchParams = parseSafeSearchParams({ diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index ee5ed5e51..1f2098532 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -47,6 +47,17 @@ export const meta: MetaFunction = (args) => { }; export default function AdminPage() { + const isStaff = useHasRole("STAFF"); + + // is dev user or is someone impersonating another user (allow them to stop) + if (!isStaff) { + return ( +
+ +
+ ); + } + return (
@@ -109,12 +120,15 @@ function FriendCodeLookUp() { function AdminActions() { const isStaff = useHasRole("STAFF"); const isAdmin = useHasRole("ADMIN"); + const isDev = useHasRole("DEV"); return (
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? : null} {DANGEROUS_CAN_ACCESS_DEV_CONTROLS ? : null} - {DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin ? : null} + {DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin || isDev ? ( + + ) : null} {isStaff ? : null} {isStaff ? : null} diff --git a/app/features/auth/core/routes.server.ts b/app/features/auth/core/routes.server.ts index d0af10012..69f57bcfa 100644 --- a/app/features/auth/core/routes.server.ts +++ b/app/features/auth/core/routes.server.ts @@ -3,8 +3,9 @@ import type { ActionFunction, LoaderFunction } from "react-router"; import { redirect } from "react-router"; import { z } from "zod"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; +import { requireUser } from "~/features/auth/core/user.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { requireRole } from "~/modules/permissions/guards.server"; +import { isAdmin, isStaff } from "~/modules/permissions/utils"; import { logger } from "~/utils/logger"; import { canAccessLohiEndpoint, @@ -74,19 +75,36 @@ export const logInAction: ActionFunction = async ({ request }) => { export const impersonateAction: ActionFunction = async ({ request }) => { if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) { - requireRole("ADMIN"); + const user = requireUser(); + if (!user.roles.includes("ADMIN") && !user.roles.includes("DEV")) { + throw new Response("Forbidden", { status: 403 }); + } + + if (user.roles.includes("DEV") && !user.roles.includes("ADMIN")) { + const url = new URL(request.url); + const targetId = Number(url.searchParams.get("id")); + if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) { + throw new Response("Forbidden", { status: 403 }); + } + } } const session = await authSessionStorage.getSession( request.headers.get("Cookie"), ); + const realUserId = session.get(SESSION_KEY); + const url = new URL(request.url); const rawId = url.searchParams.get("id"); const userId = Number(url.searchParams.get("id")); if (!rawId || Number.isNaN(userId)) throw new Response(null, { status: 400 }); + logger.info( + `Impersonation: user ${realUserId} started impersonating user ${userId}`, + ); + session.set(IMPERSONATED_SESSION_KEY, userId); throw redirect(ADMIN_PAGE, { @@ -99,6 +117,13 @@ export const stopImpersonatingAction: ActionFunction = async ({ request }) => { request.headers.get("Cookie"), ); + const realUserId = session.get(SESSION_KEY); + const impersonatedUserId = session.get(IMPERSONATED_SESSION_KEY); + + logger.info( + `Impersonation: user ${realUserId} stopped impersonating user ${impersonatedUserId}`, + ); + session.unset(IMPERSONATED_SESSION_KEY); throw redirect(ADMIN_PAGE, { diff --git a/app/features/auth/core/user.server.ts b/app/features/auth/core/user.server.ts index b00e9140a..1e9a171c0 100644 --- a/app/features/auth/core/user.server.ts +++ b/app/features/auth/core/user.server.ts @@ -1,4 +1,4 @@ -import { IMPERSONATED_SESSION_KEY } from "./authenticator.server"; +import { IMPERSONATED_SESSION_KEY, SESSION_KEY } from "./authenticator.server"; import { authSessionStorage } from "./session.server"; import { type AuthenticatedUser, getUserContext } from "./user-context.server"; @@ -24,3 +24,13 @@ export async function isImpersonating(request: Request) { return Boolean(session.get(IMPERSONATED_SESSION_KEY)); } + +export async function getRealUserId( + request: Request, +): Promise { + const session = await authSessionStorage.getSession( + request.headers.get("Cookie"), + ); + + return session.get(SESSION_KEY) as number | undefined; +} diff --git a/app/modules/permissions/mapper.server.ts b/app/modules/permissions/mapper.server.ts index 2078c140c..08d2f1de8 100644 --- a/app/modules/permissions/mapper.server.ts +++ b/app/modules/permissions/mapper.server.ts @@ -1,7 +1,7 @@ import type { UserWithPlusTier } from "~/db/tables"; import { userDiscordIdIsAged } from "~/utils/users"; import type { Role } from "./types"; -import { isAdmin, isStaff, isSupporter } from "./utils"; +import { isAdmin, isDev, isStaff, isSupporter } from "./utils"; export function userRoles( user: Pick< @@ -26,6 +26,10 @@ export function userRoles( result.push("STAFF"); } + if (isDev(user)) { + result.push("DEV"); + } + if (typeof user.patronTier === "number") { result.push("MINOR_SUPPORT"); } diff --git a/app/modules/permissions/types.ts b/app/modules/permissions/types.ts index dd492669a..40f07127f 100644 --- a/app/modules/permissions/types.ts +++ b/app/modules/permissions/types.ts @@ -14,5 +14,6 @@ export type Role = | "CALENDAR_EVENT_ADDER" | "TOURNAMENT_ADDER" | "API_ACCESSER" + | "DEV" | "SUPPORTER" // patrons of "Supporter" tier or higher | "MINOR_SUPPORT"; // patrons of "Support" tier or higher diff --git a/app/modules/permissions/utils.ts b/app/modules/permissions/utils.ts index 1ce01a570..96b81c84e 100644 --- a/app/modules/permissions/utils.ts +++ b/app/modules/permissions/utils.ts @@ -1,4 +1,4 @@ -import { ADMIN_ID, STAFF_IDS } from "~/features/admin/admin-constants"; +import { ADMIN_ID, DEV_IDS, STAFF_IDS } from "~/features/admin/admin-constants"; export function isAdmin(user?: { id: number }) { return user?.id === ADMIN_ID; @@ -10,6 +10,12 @@ export function isStaff(user?: { id: number }) { return STAFF_IDS.includes(user.id); } +export function isDev(user?: { id: number }) { + if (!user) return false; + + return DEV_IDS.includes(user.id); +} + export function isSupporter(user?: { patronTier: number | null }) { return typeof user?.patronTier === "number" && user.patronTier >= 2; }