Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,80 @@ describe("Hackathon admin management edge cases", () => {
});
});

// =====================================================================
describe("Bug tester tier (read-only QA)", () => {
const testerCaller = (rows: Record<string, unknown> = {}) =>
adminCaller(rows, "bug_tester");

/**
* Reads must get past the staff gate. Whatever a query does after that is
* its own business; it must not fail on the role.
*/
it("lets a bug tester run staff queries", async () => {
const caller = testerCaller({
hackathons: { id: HACK_A, name: "Hacklytics 2027" },
hackathonEvents: { id: EVENT_A, hackathonId: HACK_A },
});
mockFindMany.mockReturnValue([]);

const attendees = await caller.hackathon
.adminGetAttendees({ hackathonId: HACK_A })
.catch((e: Error) => e);
expect(String(attendees)).not.toMatch(/Admin access required|read-only/);

await expect(
caller.hackathon.getEventAttendees({
hackathonId: HACK_A,
eventId: EVENT_A,
}),
).resolves.toMatchObject({ matching: 0 });
});

/**
* The whole point of the tier: every write is refused at the gate, for
* full-staff, super-admin and scan-desk mutations alike, and nothing is
* written.
*/
it("refuses a bug tester every mutation", async () => {
const caller = testerCaller({
hackathons: { id: HACK_A, name: "Hacklytics 2027" },
});

await expect(
caller.hackathon.batchUpdateParticipantStatus({
hackathonId: HACK_A,
participantIds: [PART_A1],
status: "approved",
}),
).rejects.toThrow(/read-only/);

await expect(
caller.hackathon.delete({
hackathonId: HACK_A,
confirmName: "Hacklytics 2027",
}),
).rejects.toThrow(/read-only/);

await expect(
caller.hackathon.scanParticipantPass({
hackathonId: HACK_A,
eventId: EVENT_A,
participantId: PART_A1,
}),
).rejects.toThrow(/read-only/);

expect(mockUpdate).not.toHaveBeenCalled();
});

it("keeps super-admin queries closed to a bug tester", async () => {
const caller = testerCaller();

await expect(
caller.admin.findUserByEmail({ email: "someone@gatech.edu" }),
).rejects.toThrow(/Super admin access required/);
});
});

const liveHackathon = (overrides: Record<string, unknown> = {}) => ({
id: HACK_A,
name: "Hacklytics 2027",
Expand Down
38 changes: 30 additions & 8 deletions packages/api/src/middleware/procedures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
import { eq, and } from "drizzle-orm";
import { CacheKeys } from "./cache";
import { resolveHackathonId } from "../services/portal-context";
import { isStaffRole, isExpiredAdmin } from "../types/portal-context";
import {
isStaffRole,
isBugTesterRole,
isExpiredAdmin,
} from "../types/portal-context";
import type { Context } from "../context";

// Admin check for a publicProcedure that widens its response for staff.
Expand All @@ -29,8 +33,10 @@ export const callerIsAdmin = async (ctx: Context) => {
where: and(eq(admins.userId, ctx.userId), eq(admins.isActive, true)),
});

const isStaff =
!!admin && admin.role !== "volunteer" && !isExpiredAdmin(admin);
// Staff only: this widens public responses and, through isProjectLeader,
// lets the caller act on other leaders' initiatives. A bug tester gets
// neither.
const isStaff = !!admin && isStaffRole(admin.role) && !isExpiredAdmin(admin);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testers miss staff-only edition data

Medium Severity

canViewAdmin lets a bug_tester into admin tools and listAll, but callerIsAdmin still treats them as the public. hackathon.getById then 404s draft editions they just opened, hackathon.list hides draft and hidden editions on Judging, and hackathon.projects strips drafts. Testers cannot actually review those staff-only screens.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6f12ff8. Configure here.


ctx.cache.set(cacheKey, isStaff, 60);

Expand Down Expand Up @@ -67,7 +73,7 @@ const loadAdminRow = async (ctx: Context) => {
// Anyone staffing the event, volunteers included. Scoped to badge scanning
// and its undo: a 2000-person event runs several check-in stations, and those
// people should not hold the role that can delete the hackathon.
export const isScanner = protectedProcedure.use(async ({ ctx, next }) => {
export const isScanner = protectedProcedure.use(async ({ ctx, next, type }) => {
const admin = await loadAdminRow(ctx);

if (!admin) {
Expand All @@ -77,22 +83,38 @@ export const isScanner = protectedProcedure.use(async ({ ctx, next }) => {
});
}

if (isBugTesterRole(admin.role) && type !== "query") {
throw new TRPCError({ code: "FORBIDDEN", message: READ_ONLY_MESSAGE });
}

return next({ ctx: { ...ctx, admin } });
});

const READ_ONLY_MESSAGE =
"Bug testers have read-only access. Ask an admin to make this change.";

// Full staff. Volunteers are rejected here — they hold an admins row, so
// without the role check they would pass every admin gate in the API. Cached
// 60s per user to avoid a round trip on every request.
export const isAdmin = protectedProcedure.use(async ({ ctx, next }) => {
// without the role check they would pass every admin gate in the API. Bug
// testers pass for queries only: the check is on the procedure type, so every
// mutation behind this gate (and isSuperAdmin, built on it) refuses them
// without each procedure having to remember to. Cached 60s per user to avoid
// a round trip on every request.
export const isAdmin = protectedProcedure.use(async ({ ctx, next, type }) => {
const admin = await loadAdminRow(ctx);

if (!admin || !isStaffRole(admin.role)) {
const readOnly = !!admin && isBugTesterRole(admin.role);

if (!admin || (!isStaffRole(admin.role) && !readOnly)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}

if (readOnly && type !== "query") {
throw new TRPCError({ code: "FORBIDDEN", message: READ_ONLY_MESSAGE });
}

return next({ ctx: { ...ctx, admin } });
});

Expand Down
11 changes: 9 additions & 2 deletions packages/api/src/routers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,14 @@ export const adminRouter = createTRPCRouter({
// "volunteer" is the check-in desk tier: an active admins row that isAdmin
// deliberately rejects, so it grants badge scanning and nothing else. Without
// it the only way to staff a scan station is a hand-written INSERT.
role: z.enum(["super_admin", "admin", "moderator", "volunteer"]),
// "bug_tester" is read-only QA: every staff query, no mutation.
role: z.enum([
"super_admin",
"admin",
"moderator",
"volunteer",
"bug_tester",
]),
permissions: z.array(z.string().max(100)).max(50).optional(),
// Fixed-term appointment. Omitted means standing, which is what the people
// who run the club hold.
Expand Down Expand Up @@ -370,7 +377,7 @@ export const adminRouter = createTRPCRouter({
// Same set as create — otherwise an existing admin could be made a volunteer
// but a volunteer could never be promoted back.
role: z
.enum(["super_admin", "admin", "moderator", "volunteer"])
.enum(["super_admin", "admin", "moderator", "volunteer", "bug_tester"])
.optional(),
permissions: z.array(z.string().max(100)).max(50).optional(),
isActive: z.boolean().optional(),
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/services/portal-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { cache, clearMembershipCaches } from "../middleware/cache";
import {
EMPTY_MEMBER_CONTEXT,
isStaffRole,
isBugTesterRole,
isExpiredAdmin,
} from "../types/portal-context";
import type { MemberContext, PortalContext } from "../types/portal-context";
Expand Down Expand Up @@ -138,6 +139,7 @@ export async function fetchPortalContext(
// renders nav that every one of those pages rejects.
isAdmin: isStaffRole(staff?.role),
isScanner: !!staff,
isBugTester: isBugTesterRole(staff?.role),
role: staff?.role ?? null,
permissions: staff?.permissions ?? [],
isJudge: !!judgeRecord,
Expand Down
20 changes: 16 additions & 4 deletions packages/api/src/types/portal-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,21 @@ export type MemberContext = {
renewalCount: number;
};

// Full staff, as opposed to a volunteer. Lives here because both the
// middleware and the portal context need it, and procedures.ts already
// imports from the portal-context service — putting it there closes a cycle.
// Full staff, as opposed to a volunteer or a bug tester. Lives here because
// both the middleware and the portal context need it, and procedures.ts
// already imports from the portal-context service — putting it there closes a
// cycle. An allow-list, not "anything but volunteer": a role added later must
// not become full staff by default.
export const STAFF_ROLES = ["super_admin", "admin", "moderator"] as const;

export const isStaffRole = (role: string | null | undefined) =>
!!role && role !== "volunteer";
!!role && (STAFF_ROLES as readonly string[]).includes(role);

// Read-only QA. Sees what staff see; every write is refused server-side.
export const BUG_TESTER_ROLE = "bug_tester";

export const isBugTesterRole = (role: string | null | undefined) =>
role === BUG_TESTER_ROLE;

// A fixed-term appointment that has run out. Checked here rather than in each
// query's WHERE so the row still comes back: the staff page has to show an
Expand All @@ -32,6 +42,8 @@ export type PortalContext = {
isAdmin: boolean;
/** Any active admins row, volunteers included — may staff a check-in desk. */
isScanner: boolean;
/** Read-only QA: can open the admin pages, cannot change anything. */
isBugTester: boolean;
role: string | null;
permissions: string[];
isJudge: boolean;
Expand Down
4 changes: 3 additions & 1 deletion packages/db/src/schemas/admins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ export const admins = pgTable("admin", {
// "volunteer" is the weakest tier and is NOT full staff: it lets the people
// running check-in desks scan badges without holding the role that can delete
// the hackathon. isAdmin rejects it; only the scanner procedures accept it.
// "bug_tester" is read-only QA: it may run every staff query but no
// mutation (enforced in the isAdmin / isScanner middleware).
role: text("role", {
enum: ["super_admin", "admin", "moderator", "volunteer"],
enum: ["super_admin", "admin", "moderator", "volunteer", "bug_tester"],
})
.notNull()
.default("admin"),
Expand Down
7 changes: 6 additions & 1 deletion sites/mainweb/app/(portal)/admin/bootcamp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { BootcampWorkshopFormData } from "@/components/portal/BootcampWorks
import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal";
import { useEventQR } from "@/components/portal/EventQR";
import { trpc } from "@/lib/trpc";
import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context";
import {
body,
btnPrimary,
Expand Down Expand Up @@ -142,6 +143,7 @@ export default function AdminBootcampPage() {
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const utils = trpc.useUtils();
const readOnly = useReadOnly();

const attendance = trpc.bootcamp.attendance.useQuery(
{ term },
Expand Down Expand Up @@ -447,6 +449,8 @@ export default function AdminBootcampPage() {
setError(null);
setModalOpen(true);
}}
disabled={readOnly}
title={readOnly ? READ_ONLY_TITLE : undefined}
className={btnPrimary}
>
Add workshop
Expand Down Expand Up @@ -566,7 +570,8 @@ export default function AdminBootcampPage() {
enabled: !row.checkInEnabled,
})
}
disabled={toggleCheckIn.isPending}
disabled={readOnly || toggleCheckIn.isPending}
title={readOnly ? READ_ONLY_TITLE : undefined}
className={`${btnSecondary} min-h-11`}
>
{row.checkInEnabled ? "Close check-in" : "Open check-in"}
Expand Down
6 changes: 3 additions & 3 deletions sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useSession } from "next-auth/react";
import { trpc } from "@/lib/trpc";
import { usePortalContext } from "@/lib/use-portal-context";
import { canViewAdmin, usePortalContext } from "@/lib/use-portal-context";
import { useParams } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
Expand Down Expand Up @@ -59,7 +59,7 @@ export default function AdminHackathonDashboard() {
refetch,
} = trpc.hackathon.getById.useQuery(
{ id: hackathonId },
{ enabled: !!hackathonId && !!portalContext?.isAdmin },
{ enabled: !!hackathonId && canViewAdmin(portalContext) },
);

// isLoading is false on the render where the query flips enabled but has not
Expand All @@ -75,7 +75,7 @@ export default function AdminHackathonDashboard() {
// Not found rather than a permission error: telling a non-admin that this
// edition exists behind a door they cannot open is the whole leak, and the
// query is disabled for them anyway, so waiting on it spun forever.
if (!portalContext?.isAdmin) {
if (!canViewAdmin(portalContext)) {
return <DashboardUnavailable message="Hackathon not found" />;
}

Expand Down
6 changes: 6 additions & 0 deletions sites/mainweb/app/(portal)/admin/hackathons/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useSession } from "next-auth/react";
import { loginHref } from "@/lib/safe-callback";
import { trpc } from "@/lib/trpc";
import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
import { useRouter } from "next/navigation";
import { useState } from "react";
Expand All @@ -21,6 +22,7 @@ export default function AdminHackathonsPage() {
const { data: session, status } = useSession();
const router = useRouter();
const utils = trpc.useUtils();
const readOnly = useReadOnly();

const [showCreate, setShowCreate] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
Expand Down Expand Up @@ -56,6 +58,8 @@ export default function AdminHackathonsPage() {
<button
type="button"
onClick={() => setShowCreate(true)}
disabled={readOnly}
title={readOnly ? READ_ONLY_TITLE : undefined}
className={`shrink-0 self-start sm:self-auto ${btnPrimary}`}
>
New hackathon
Expand Down Expand Up @@ -119,6 +123,8 @@ export default function AdminHackathonsPage() {
<button
type="button"
onClick={() => setShowCreate(true)}
disabled={readOnly}
title={readOnly ? READ_ONLY_TITLE : undefined}
className={btnSecondary}
>
Create the first one
Expand Down
Loading
Loading