Skip to content

Commit a9cb0e7

Browse files
committed
RBAC: dashboardLoader / dashboardAction + migrate admin pages (TRI-8717)
Add a session-auth route builder analogous to apiBuilder.server.ts that routes dashboard auth through rbac.authenticateSession and runs the ability check (canSuper or can) before the handler runs. Routes that only need a logged-in user (no authorisation) keep using requireUser / requireUserId — the builder is opt-in for routes with explicit auth. Builder shape: dashboardLoader({ authorization: { requireSuper: true } }, async ({ user, ability }) => ...) dashboardLoader({ authorization: { action, resource } }, ...) dashboardAction(...) Auth failure throws a redirect Response so the success-path return type stays narrow (useTypedLoaderData<typeof loader>() picks up the handler's TypedJsonResponse). Optional context callback feeds organizationId / projectId to authenticateSession when needed (enterprise-only — fallback ignores context today). Migrated 14 platform admin routes from `requireUser` + `if (!user.admin)` to dashboardLoader / dashboardAction with requireSuper: true: admin.tsx admin._index.tsx admin.concurrency.tsx admin.feature-flags.tsx admin.notifications.tsx admin.orgs.tsx admin.data-stores.tsx admin.back-office.tsx admin.back-office._index.tsx admin.back-office.orgs.$orgId.tsx admin.llm-models._index.tsx admin.llm-models.$modelId.tsx admin.llm-models.new.tsx admin.llm-models.missing._index.tsx admin.llm-models.missing.$model.tsx Routes that have admin-only sub-features (e.g. show-extra-fields-if-admin on otherwise public routes) stay on requireUser. Migration of those is a separate concern — they don't gate access on admin, they just branch display. Behavioural change: action handlers that previously threw `new Response('Unauthorized', { status: 403 })` on non-admins now redirect to / along with the loader. Uniform behaviour, but XHR fetchers that expected a 403 status would now follow the redirect instead. The admin pages migrated here don't appear to have XHR fetchers that depend on the 403, but worth flagging. Verification: - pnpm run typecheck --filter webapp: clean. - pnpm run test --filter @internal/rbac: 31 unit tests pass. - E2E suite: all 31 tests pass — including the /admin/concurrency redirect test (now exercising the new builder).
1 parent 82cb5d3 commit a9cb0e7

16 files changed

Lines changed: 767 additions & 588 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Add `dashboardLoader` / `dashboardAction` route builders that route session auth through the RBAC plugin (`rbac.authenticateSession` + `ability.canSuper()` / `ability.can`) and migrate the platform admin pages onto them. Routes that only need a logged-in user with no authorisation continue to use `requireUser` / `requireUserId` — the builder is opt-in for routes with explicit auth checks. Migrated routes: `admin.tsx`, `admin._index.tsx`, `admin.concurrency.tsx`, `admin.feature-flags.tsx`, `admin.notifications.tsx`, `admin.orgs.tsx`, `admin.data-stores.tsx`, `admin.back-office.tsx` (+ children), `admin.llm-models.*` (5 routes). Behavioural change: actions that previously threw `403 Unauthorized` on non-admins now redirect to `/` along with the loader — uniform with the builder's behaviour.

apps/webapp/app/routes/admin._index.tsx

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
22
import { Form } from "@remix-run/react";
3-
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
4-
import { redirect } from "@remix-run/server-runtime";
53
import { typedjson, useTypedLoaderData } from "remix-typedjson";
64
import { z } from "zod";
75
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -22,7 +20,7 @@ import {
2220
import { useUser } from "~/hooks/useUser";
2321
import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server";
2422
import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server";
25-
import { requireUserId } from "~/services/session.server";
23+
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder.server";
2624
import { createSearchParams } from "~/utils/searchParams";
2725

2826
export const SearchParams = z.object({
@@ -32,30 +30,34 @@ export const SearchParams = z.object({
3230

3331
export type SearchParams = z.infer<typeof SearchParams>;
3432

35-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
36-
const userId = await requireUserId(request);
33+
export const loader = dashboardLoader(
34+
{ authorization: { requireSuper: true } },
35+
async ({ user, request }) => {
36+
const searchParams = createSearchParams(request.url, SearchParams);
37+
if (!searchParams.success) {
38+
throw new Error(searchParams.error);
39+
}
40+
const result = await adminGetUsers(user.id, searchParams.params.getAll());
3741

38-
const searchParams = createSearchParams(request.url, SearchParams);
39-
if (!searchParams.success) {
40-
throw new Error(searchParams.error);
42+
return typedjson(result);
4143
}
42-
const result = await adminGetUsers(userId, searchParams.params.getAll());
43-
44-
return typedjson(result);
45-
};
44+
);
4645

4746
const FormSchema = z.object({ id: z.string() });
4847

49-
export async function action({ request }: ActionFunctionArgs) {
50-
if (request.method.toLowerCase() !== "post") {
51-
return new Response("Method not allowed", { status: 405 });
52-
}
48+
export const action = dashboardAction(
49+
{ authorization: { requireSuper: true } },
50+
async ({ request }) => {
51+
if (request.method.toLowerCase() !== "post") {
52+
return new Response("Method not allowed", { status: 405 });
53+
}
5354

54-
const payload = Object.fromEntries(await request.formData());
55-
const { id } = FormSchema.parse(payload);
55+
const payload = Object.fromEntries(await request.formData());
56+
const { id } = FormSchema.parse(payload);
5657

57-
return redirectWithImpersonation(request, id, "/");
58-
}
58+
return redirectWithImpersonation(request, id, "/");
59+
}
60+
);
5961

6062
export default function AdminDashboardRoute() {
6163
const user = useUser();

apps/webapp/app/routes/admin.back-office._index.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
1-
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
2-
import { redirect, typedjson } from "remix-typedjson";
1+
import { typedjson } from "remix-typedjson";
32
import { LinkButton } from "~/components/primitives/Buttons";
43
import { Header2 } from "~/components/primitives/Headers";
54
import { Paragraph } from "~/components/primitives/Paragraph";
6-
import { requireUser } from "~/services/session.server";
5+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder.server";
76

8-
export async function loader({ request }: LoaderFunctionArgs) {
9-
const user = await requireUser(request);
10-
if (!user.admin) {
11-
return redirect("/");
7+
export const loader = dashboardLoader(
8+
{ authorization: { requireSuper: true } },
9+
async () => {
10+
return typedjson({});
1211
}
13-
return typedjson({});
14-
}
12+
);
1513

1614
export default function BackOfficeIndex() {
1715
return (

apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx

Lines changed: 79 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Form, useNavigation, useSearchParams } from "@remix-run/react";
2-
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
32
import { useEffect, useState } from "react";
43
import { redirect, typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson";
54
import { z } from "zod";
@@ -19,7 +18,7 @@ import {
1918
} from "~/services/authorizationRateLimitMiddleware.server";
2019
import { logger } from "~/services/logger.server";
2120
import { type Duration } from "~/services/rateLimiter.server";
22-
import { requireUser } from "~/services/session.server";
21+
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder.server";
2322

2423
const SAVED_QUERY_KEY = "saved";
2524
const SAVED_QUERY_VALUE = "1";
@@ -98,39 +97,38 @@ function describeRateLimit(
9897
};
9998
}
10099

101-
export async function loader({ request, params }: LoaderFunctionArgs) {
102-
const user = await requireUser(request);
103-
if (!user.admin) {
104-
return redirect("/");
105-
}
106-
107-
const orgId = params.orgId;
108-
if (!orgId) {
109-
throw new Response(null, { status: 404 });
110-
}
100+
const ParamsSchema = z.object({
101+
orgId: z.string(),
102+
});
111103

112-
const org = await prisma.organization.findFirst({
113-
where: { id: orgId },
114-
select: {
115-
id: true,
116-
slug: true,
117-
title: true,
118-
createdAt: true,
119-
apiRateLimiterConfig: true,
120-
},
121-
});
122-
123-
if (!org) {
124-
throw new Response(null, { status: 404 });
125-
}
104+
export const loader = dashboardLoader(
105+
{ authorization: { requireSuper: true }, params: ParamsSchema },
106+
async ({ params }) => {
107+
const { orgId } = params;
108+
109+
const org = await prisma.organization.findFirst({
110+
where: { id: orgId },
111+
select: {
112+
id: true,
113+
slug: true,
114+
title: true,
115+
createdAt: true,
116+
apiRateLimiterConfig: true,
117+
},
118+
});
119+
120+
if (!org) {
121+
throw new Response(null, { status: 404 });
122+
}
126123

127-
const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig);
124+
const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig);
128125

129-
return typedjson({
130-
org,
131-
effective,
132-
});
133-
}
126+
return typedjson({
127+
org,
128+
effective,
129+
});
130+
}
131+
);
134132

135133
const SetRateLimitSchema = z.object({
136134
intent: z.literal("set-rate-limit"),
@@ -144,64 +142,59 @@ const SetRateLimitSchema = z.object({
144142
maxTokens: z.coerce.number().int().min(1),
145143
});
146144

147-
export async function action({ request, params }: ActionFunctionArgs) {
148-
const user = await requireUser(request);
149-
if (!user.admin) {
150-
return redirect("/");
151-
}
152-
153-
const orgId = params.orgId;
154-
if (!orgId) {
155-
throw new Response(null, { status: 404 });
156-
}
157-
158-
const formData = await request.formData();
159-
const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData));
160-
if (!submission.success) {
161-
return typedjson(
162-
{ errors: submission.error.flatten().fieldErrors },
163-
{ status: 400 }
164-
);
165-
}
145+
export const action = dashboardAction(
146+
{ authorization: { requireSuper: true }, params: ParamsSchema },
147+
async ({ user, params, request }) => {
148+
const { orgId } = params;
149+
150+
const formData = await request.formData();
151+
const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData));
152+
if (!submission.success) {
153+
return typedjson(
154+
{ errors: submission.error.flatten().fieldErrors },
155+
{ status: 400 }
156+
);
157+
}
166158

167-
const existing = await prisma.organization.findFirst({
168-
where: { id: orgId },
169-
select: { apiRateLimiterConfig: true },
170-
});
171-
if (!existing) {
172-
throw new Response(null, { status: 404 });
173-
}
159+
const existing = await prisma.organization.findFirst({
160+
where: { id: orgId },
161+
select: { apiRateLimiterConfig: true },
162+
});
163+
if (!existing) {
164+
throw new Response(null, { status: 404 });
165+
}
174166

175-
const built = RateLimitTokenBucketConfig.safeParse({
176-
type: "tokenBucket",
177-
refillRate: submission.data.refillRate,
178-
interval: submission.data.interval,
179-
maxTokens: submission.data.maxTokens,
180-
});
181-
if (!built.success) {
182-
return typedjson(
183-
{ errors: built.error.flatten().fieldErrors },
184-
{ status: 400 }
167+
const built = RateLimitTokenBucketConfig.safeParse({
168+
type: "tokenBucket",
169+
refillRate: submission.data.refillRate,
170+
interval: submission.data.interval,
171+
maxTokens: submission.data.maxTokens,
172+
});
173+
if (!built.success) {
174+
return typedjson(
175+
{ errors: built.error.flatten().fieldErrors },
176+
{ status: 400 }
177+
);
178+
}
179+
const next = built.data;
180+
181+
await prisma.organization.update({
182+
where: { id: orgId },
183+
data: { apiRateLimiterConfig: next as any },
184+
});
185+
186+
logger.info("admin.backOffice.rateLimit", {
187+
adminUserId: user.id,
188+
orgId,
189+
previous: existing.apiRateLimiterConfig,
190+
next,
191+
});
192+
193+
return redirect(
194+
`/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${SAVED_QUERY_VALUE}`
185195
);
186196
}
187-
const next = built.data;
188-
189-
await prisma.organization.update({
190-
where: { id: orgId },
191-
data: { apiRateLimiterConfig: next as any },
192-
});
193-
194-
logger.info("admin.backOffice.rateLimit", {
195-
adminUserId: user.id,
196-
orgId,
197-
previous: existing.apiRateLimiterConfig,
198-
next,
199-
});
200-
201-
return redirect(
202-
`/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${SAVED_QUERY_VALUE}`
203-
);
204-
}
197+
);
205198

206199
export default function BackOfficeOrgPage() {
207200
const { org, effective } = useTypedLoaderData<typeof loader>();

apps/webapp/app/routes/admin.back-office.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import { Outlet } from "@remix-run/react";
2-
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
3-
import { redirect, typedjson } from "remix-typedjson";
4-
import { requireUser } from "~/services/session.server";
2+
import { typedjson } from "remix-typedjson";
3+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder.server";
54

6-
export async function loader({ request }: LoaderFunctionArgs) {
7-
const user = await requireUser(request);
8-
if (!user.admin) {
9-
return redirect("/");
5+
export const loader = dashboardLoader(
6+
{ authorization: { requireSuper: true } },
7+
async () => {
8+
return typedjson({});
109
}
11-
return typedjson({});
12-
}
10+
);
1311

1412
export default function BackOfficeLayout() {
1513
return (

apps/webapp/app/routes/admin.concurrency.tsx

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
import { InformationCircleIcon } from "@heroicons/react/20/solid";
2-
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
3-
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
2+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
43
import { Header1 } from "~/components/primitives/Headers";
54
import { InfoPanel } from "~/components/primitives/InfoPanel";
65
import { Paragraph } from "~/components/primitives/Paragraph";
7-
import { rbac } from "~/services/rbac.server";
6+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder.server";
87
import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server";
98

10-
export const loader = async ({ request }: LoaderFunctionArgs) => {
11-
const auth = await rbac.authenticateSession(request, {});
12-
if (!auth.ok) return redirect("/login");
13-
if (!auth.ability.canSuper()) return redirect("/");
14-
15-
const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true);
16-
const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false);
17-
18-
return typedjson({ deployedConcurrency, devConcurrency });
19-
};
9+
export const loader = dashboardLoader(
10+
{ authorization: { requireSuper: true } },
11+
async () => {
12+
const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true);
13+
const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false);
14+
return typedjson({ deployedConcurrency, devConcurrency });
15+
}
16+
);
2017

2118
export default function AdminDashboardRoute() {
2219
const { deployedConcurrency, devConcurrency } = useTypedLoaderData<typeof loader>();

0 commit comments

Comments
 (0)