Skip to content

Commit 5740955

Browse files
authored
feat(webapp): enforce RBAC permissions on run, prompt, member, and billing routes (#3948)
## Summary Several dashboard routes performed actions a restricted role should not be able to do (cancel or replay runs, manage prompt versions, invite and manage members, manage billing) without any permission check. This adds role-based permission enforcement to those routes, and disables the matching UI controls (with a tooltip) when the current role lacks permission. Covered actions: - Runs: cancel and replay (single, bulk create, bulk abort) - Prompts: create or edit override versions, and promote a version to current - Members: invite, resend invite, revoke invite - Billing: change plan, billing alerts, and the customer portal ## How Each affected route now goes through the `dashboardLoader` / `dashboardAction` route builders with an `authorization` block declaring the required permission (or a per-intent check where one route handles several intents). Existing tenancy and data-scoping queries are untouched; this only layers permission checks on top. The UI follows disable-don't-hide: controls stay visible but disabled with a "You don't have permission to ..." tooltip. Two reusable pieces support this: `checkPermissions(ability, checks)` turns a set of checks into a boolean map a loader returns to the client, and `PermissionButton` / `PermissionLink` disable the underlying control and show a tooltip when a permission flag is false. ## Behaviour No change in the default configuration: permissions are permissive, so every control stays enabled and every route behaves as before. The checks only take effect when an RBAC plugin is installed. This also makes role assignment on invite-accept non-fatal, so a failure there cannot block joining an org. Verified with `pnpm run typecheck --filter webapp`; `checkPermissions` has unit tests.
1 parent ca43ab8 commit 5740955

53 files changed

Lines changed: 4208 additions & 2471 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Enforce role-based permissions across the dashboard and API. New permission boundaries cover: runs (cancel, replay, bulk actions), deployments (rollback, promote, cancel), prompt versions, organization members (invite, resend, revoke), billing and seat purchases, integrations (GitHub and Vercel), and environment variables and API keys (restricted by environment tier). Roles without access can no longer read or change these, gated controls are disabled with a tooltip, and gated pages show a permission-denied panel instead of redirecting away. Behaviour is unchanged in the default configuration, where permissions stay permissive.

apps/webapp/app/components/ErrorDisplay.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { HomeIcon } from "@heroicons/react/20/solid";
22
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
33
import { friendlyErrorDisplay } from "~/utils/httpErrors";
4+
import { permissionDeniedMessage } from "~/utils/permissionDenied";
45
import { LinkButton } from "./primitives/Buttons";
56
import { Header1 } from "./primitives/Headers";
67
import { Paragraph } from "./primitives/Paragraph";
8+
import { PermissionDenied } from "./PermissionDenied";
79
import { TriggerRotatingLogo } from "./TriggerRotatingLogo";
810
import { type ReactNode } from "react";
911

@@ -17,6 +19,21 @@ type ErrorDisplayOptions = {
1719
export function RouteErrorDisplay(options?: ErrorDisplayOptions) {
1820
const error = useRouteError();
1921

22+
// A failed `authorization` check (or `throwPermissionDenied`) throws a 403
23+
// that bubbles to the nearest route ErrorBoundary. Every layout boundary
24+
// renders through here, so handling it once means a gated route only has to
25+
// declare `authorization` to get the permission panel: no per-route boundary.
26+
const permission = isRouteErrorResponse(error) ? permissionDeniedMessage(error.data) : null;
27+
if (permission) {
28+
return (
29+
<div className="flex min-h-screen w-full items-center justify-center p-4">
30+
<div className="w-full max-w-md">
31+
<PermissionDenied message={permission} />
32+
</div>
33+
</div>
34+
);
35+
}
36+
2037
return (
2138
<>
2239
{isRouteErrorResponse(error) ? (
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NoSymbolIcon } from "@heroicons/react/20/solid";
2+
import React from "react";
3+
import { useOptionalOrganization } from "~/hooks/useOrganizations";
4+
import { organizationRolesPath } from "~/utils/pathBuilder";
5+
import { LinkButton } from "./primitives/Buttons";
6+
import { InfoPanel } from "./primitives/InfoPanel";
7+
8+
export function PermissionDenied({ message }: { message: React.ReactNode }) {
9+
const organization = useOptionalOrganization();
10+
11+
return (
12+
<InfoPanel
13+
icon={NoSymbolIcon}
14+
iconClassName="text-text-dimmed"
15+
title="Permission denied"
16+
accessory={
17+
organization ? (
18+
<LinkButton to={organizationRolesPath(organization)} variant="secondary/small">
19+
View roles
20+
</LinkButton>
21+
) : undefined
22+
}
23+
>
24+
{message}
25+
</InfoPanel>
26+
);
27+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { forwardRef, type ReactNode } from "react";
2+
import { Button } from "./Buttons";
3+
4+
export const DEFAULT_NO_PERMISSION_TOOLTIP = "You don't have permission to do this";
5+
6+
type PermissionButtonProps = React.ComponentProps<typeof Button> & {
7+
/** Server-computed flag (see `checkPermissions`). When false the button is disabled with a tooltip. */
8+
hasPermission: boolean;
9+
noPermissionTooltip?: ReactNode;
10+
};
11+
12+
/**
13+
* A `Button` that disables itself and shows an explanatory tooltip when the
14+
* user lacks permission. Display only — the server route builder's
15+
* `authorization` block is the real gate. `Button` already renders its
16+
* `tooltip` while disabled (it wraps the disabled button in a hoverable span),
17+
* so we reuse that path.
18+
*/
19+
export const PermissionButton = forwardRef<HTMLButtonElement, PermissionButtonProps>(
20+
({ hasPermission, noPermissionTooltip, disabled, tooltip, ...props }, ref) => {
21+
if (hasPermission) {
22+
return <Button ref={ref} disabled={disabled} tooltip={tooltip} {...props} />;
23+
}
24+
25+
return (
26+
<Button
27+
ref={ref}
28+
{...props}
29+
disabled
30+
tooltip={noPermissionTooltip ?? DEFAULT_NO_PERMISSION_TOOLTIP}
31+
/>
32+
);
33+
}
34+
);
35+
36+
PermissionButton.displayName = "PermissionButton";
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { type ReactNode } from "react";
2+
import { cn } from "~/utils/cn";
3+
import { ButtonContent, type ButtonContentPropsType, LinkButton } from "./Buttons";
4+
import { SimpleTooltip } from "./Tooltip";
5+
import { DEFAULT_NO_PERMISSION_TOOLTIP } from "./PermissionButton";
6+
7+
type PermissionLinkProps = React.ComponentProps<typeof LinkButton> & {
8+
/** Server-computed flag (see `checkPermissions`). When false the link is disabled with a tooltip. */
9+
hasPermission: boolean;
10+
noPermissionTooltip?: ReactNode;
11+
};
12+
13+
/**
14+
* A `LinkButton` that disables itself and shows an explanatory tooltip when the
15+
* user lacks permission. Display only — the server route builder's
16+
* `authorization` block is the real gate. Unlike `Button`, `LinkButton` has no
17+
* tooltip support and renders a `pointer-events-none` element when disabled
18+
* (which can't be hovered), so the denied state renders a `SimpleTooltip`
19+
* around a non-interactive `ButtonContent` instead — the same pattern the team
20+
* settings page uses for its gated controls.
21+
*/
22+
export function PermissionLink({
23+
hasPermission,
24+
noPermissionTooltip,
25+
...props
26+
}: PermissionLinkProps) {
27+
if (hasPermission) {
28+
return <LinkButton {...props} />;
29+
}
30+
31+
return (
32+
<SimpleTooltip
33+
button={
34+
<ButtonContent
35+
{...(props as ButtonContentPropsType)}
36+
className={cn(props.className, "cursor-not-allowed opacity-50")}
37+
/>
38+
}
39+
content={noPermissionTooltip ?? DEFAULT_NO_PERMISSION_TOOLTIP}
40+
disableHoverableContent
41+
/>
42+
);
43+
}

apps/webapp/app/components/runs/v3/TaskRunsTable.tsx

Lines changed: 101 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ type RunsTableProps = {
7979
showTopBorder?: boolean;
8080
stickyHeader?: boolean;
8181
childrenStatusesBasePath?: string;
82+
/**
83+
* Display-only write:runs flags from the caller's loader. Default true so
84+
* callers that don't pass them (and OSS, where the ability is permissive)
85+
* keep the controls enabled. The cancel/replay action routes enforce
86+
* write:runs regardless.
87+
*/
88+
canCancelRuns?: boolean;
89+
canReplayRuns?: boolean;
8290
};
8391

8492
export function TaskRunsTable({
@@ -95,6 +103,8 @@ export function TaskRunsTable({
95103
showTopBorder = true,
96104
stickyHeader = false,
97105
childrenStatusesBasePath,
106+
canCancelRuns = true,
107+
canReplayRuns = true,
98108
}: RunsTableProps) {
99109
const regions = useRegions();
100110
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
@@ -512,7 +522,12 @@ export function TaskRunsTable({
512522
{run.tags.map((tag) => <RunTag key={tag} tag={tag} />) || "–"}
513523
</div>
514524
</TableCell>
515-
<RunActionsCell run={run} path={path} />
525+
<RunActionsCell
526+
run={run}
527+
path={path}
528+
canCancelRuns={canCancelRuns}
529+
canReplayRuns={canReplayRuns}
530+
/>
516531
</TableRow>
517532
);
518533
})
@@ -530,7 +545,17 @@ export function TaskRunsTable({
530545
);
531546
}
532547

533-
function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
548+
function RunActionsCell({
549+
run,
550+
path,
551+
canCancelRuns,
552+
canReplayRuns,
553+
}: {
554+
run: NextRunListItem;
555+
path: string;
556+
canCancelRuns: boolean;
557+
canReplayRuns: boolean;
558+
}) {
534559
const location = useLocation();
535560

536561
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
@@ -546,57 +571,85 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
546571
leadingIconClassName="text-blue-500"
547572
title="View run"
548573
/>
549-
{run.isCancellable && (
550-
<Dialog>
551-
<DialogTrigger
552-
asChild
553-
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
554-
>
555-
<Button
556-
variant="small-menu-item"
557-
LeadingIcon={NoSymbolIcon}
558-
leadingIconClassName="text-error"
559-
fullWidth
560-
textAlignLeft
561-
className="w-full px-1.5 py-[0.9rem]"
574+
{run.isCancellable &&
575+
(canCancelRuns ? (
576+
<Dialog>
577+
<DialogTrigger
578+
asChild
579+
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
562580
>
563-
Cancel run
564-
</Button>
565-
</DialogTrigger>
566-
<CancelRunDialog
567-
runFriendlyId={run.friendlyId}
568-
redirectPath={`${location.pathname}${location.search}`}
569-
/>
570-
</Dialog>
571-
)}
572-
{run.isReplayable && (
573-
<Dialog>
574-
<DialogTrigger
575-
asChild
576-
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
581+
<Button
582+
variant="small-menu-item"
583+
LeadingIcon={NoSymbolIcon}
584+
leadingIconClassName="text-error"
585+
fullWidth
586+
textAlignLeft
587+
className="w-full px-1.5 py-[0.9rem]"
588+
>
589+
Cancel run
590+
</Button>
591+
</DialogTrigger>
592+
<CancelRunDialog
593+
runFriendlyId={run.friendlyId}
594+
redirectPath={`${location.pathname}${location.search}`}
595+
/>
596+
</Dialog>
597+
) : (
598+
<Button
599+
variant="small-menu-item"
600+
LeadingIcon={NoSymbolIcon}
601+
leadingIconClassName="text-error"
602+
fullWidth
603+
textAlignLeft
604+
className="w-full px-1.5 py-[0.9rem]"
605+
disabled
606+
tooltip="You don't have permission to cancel runs"
577607
>
578-
<Button
579-
variant="small-menu-item"
580-
LeadingIcon={ArrowPathIcon}
581-
leadingIconClassName="text-success"
582-
fullWidth
583-
textAlignLeft
584-
className="w-full px-1.5 py-[0.9rem]"
608+
Cancel run
609+
</Button>
610+
))}
611+
{run.isReplayable &&
612+
(canReplayRuns ? (
613+
<Dialog>
614+
<DialogTrigger
615+
asChild
616+
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
585617
>
586-
Replay run…
587-
</Button>
588-
</DialogTrigger>
589-
<ReplayRunDialog
590-
runFriendlyId={run.friendlyId}
591-
failedRedirect={`${location.pathname}${location.search}`}
592-
/>
593-
</Dialog>
594-
)}
618+
<Button
619+
variant="small-menu-item"
620+
LeadingIcon={ArrowPathIcon}
621+
leadingIconClassName="text-success"
622+
fullWidth
623+
textAlignLeft
624+
className="w-full px-1.5 py-[0.9rem]"
625+
>
626+
Replay run…
627+
</Button>
628+
</DialogTrigger>
629+
<ReplayRunDialog
630+
runFriendlyId={run.friendlyId}
631+
failedRedirect={`${location.pathname}${location.search}`}
632+
/>
633+
</Dialog>
634+
) : (
635+
<Button
636+
variant="small-menu-item"
637+
LeadingIcon={ArrowPathIcon}
638+
leadingIconClassName="text-success"
639+
fullWidth
640+
textAlignLeft
641+
className="w-full px-1.5 py-[0.9rem]"
642+
disabled
643+
tooltip="You don't have permission to replay runs"
644+
>
645+
Replay run…
646+
</Button>
647+
))}
595648
</>
596649
}
597650
hiddenButtons={
598651
<>
599-
{run.isCancellable && (
652+
{run.isCancellable && canCancelRuns && (
600653
<SimpleTooltip
601654
button={
602655
<Dialog>
@@ -617,10 +670,10 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
617670
disableHoverableContent
618671
/>
619672
)}
620-
{run.isCancellable && run.isReplayable && (
673+
{run.isCancellable && canCancelRuns && run.isReplayable && canReplayRuns && (
621674
<div className="mx-0.5 h-6 w-px bg-grid-dimmed" />
622675
)}
623-
{run.isReplayable && (
676+
{run.isReplayable && canReplayRuns && (
624677
<SimpleTooltip
625678
button={
626679
<Dialog>

0 commit comments

Comments
 (0)