Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0a70472
feat(webapp): add permission-gating primitives
matt-aitken Jun 11, 2026
0445abd
fix(webapp): enforce write:runs on single-run cancel and replay actions
matt-aitken Jun 11, 2026
2a4616d
Remove the Create role UI for now
matt-aitken Jun 11, 2026
184ff28
fix(webapp): enforce write:runs on bulk action create and abort
matt-aitken Jun 11, 2026
164afd3
fix(webapp): gate run-detail Replay and Cancel buttons on write:runs
matt-aitken Jun 11, 2026
5012c6c
fix(webapp): make RBAC role assignment on invite accept non-fatal
matt-aitken Jun 12, 2026
4dd00eb
fix(webapp): enforce write:prompts / update:prompts on prompt detail …
matt-aitken Jun 12, 2026
6560bc7
fix(webapp): enforce manage:members on invite/resend/revoke routes + UI
matt-aitken Jun 12, 2026
4747366
fix(webapp): enforce manage:billing on billing/plan/portal routes
matt-aitken Jun 12, 2026
893f548
fix(webapp): gate TaskRunsTable row menu + runs-index/errors bulk con…
matt-aitken Jun 15, 2026
65fbca5
chore(webapp): add server-changes note for RBAC route permission enfo…
matt-aitken Jun 15, 2026
6ebbcee
fix(webapp): restore manage:billing enforcement on billing + billing-…
matt-aitken Jun 15, 2026
27320a6
fix(webapp): enforce write:deployments on rollback/promote/cancel rou…
matt-aitken Jun 15, 2026
096045b
fix(webapp): enforce write:github on the GitHub integration route
matt-aitken Jun 15, 2026
54ffcc1
fix(webapp): gate GitHub integration UI + install entry on write:github
matt-aitken Jun 15, 2026
01792cb
fix(webapp): enforce write:vercel on Vercel integration routes + UI
matt-aitken Jun 15, 2026
60ab3a1
fix(webapp): disable seat purchase without manage:billing
matt-aitken Jun 16, 2026
affc5d1
feat(webapp): gate API keys page on env-tier read access
matt-aitken Jun 16, 2026
da962bd
fix(webapp): show a permission panel instead of redirecting on gated …
matt-aitken Jun 16, 2026
c287f6d
feat(webapp): roles page full-height sticky table + current role + en…
matt-aitken Jun 16, 2026
98408ef
feat(webapp): enforce env var permissions on the dashboard
matt-aitken Jun 16, 2026
010c896
feat(webapp): enforce env var permissions on the API routes
matt-aitken Jun 16, 2026
ca0ecfc
docs(webapp): drop tracker reference from invite role comment
matt-aitken Jun 16, 2026
c6db6be
feat(webapp): enforce env-tier access on environment credential endpo…
matt-aitken Jun 16, 2026
97ff6f9
chore(webapp): add server-changes note for RBAC permission enforcement
matt-aitken Jun 16, 2026
37d1f9f
fix(webapp): show a permission panel on the integrations page for res…
matt-aitken Jun 17, 2026
b1b2d61
refactor(webapp): render permission-denied via an error boundary
matt-aitken Jun 17, 2026
e4fe85e
refactor(webapp): render the permission panel via the route error bou…
matt-aitken Jun 17, 2026
625c8fe
Super admin redirects
matt-aitken Jun 17, 2026
47904d1
fix(webapp): harden RBAC org scoping and write-tier gating
matt-aitken Jun 17, 2026
43bdfd6
fix(webapp): surface promote/rollback action errors on the form
matt-aitken Jun 17, 2026
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
6 changes: 6 additions & 0 deletions .server-changes/rbac-permission-enforcement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Enforce role-based permissions across the dashboard and API. Roles without access to a resource (environment variables, API keys, deployments, integrations, members, billing) can no longer read or change it, and gated pages now show a permission-denied panel instead of redirecting away.
6 changes: 6 additions & 0 deletions .server-changes/rbac-route-permission-enforcement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Enforce role-based permissions on the dashboard routes for cancelling and replaying runs, managing prompt versions, inviting and managing organisation members, and managing billing, disabling the matching controls with a tooltip when your role lacks permission. Behaviour is unchanged in the default configuration, where permissions stay permissive.
17 changes: 17 additions & 0 deletions apps/webapp/app/components/ErrorDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { HomeIcon } from "@heroicons/react/20/solid";
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
import { friendlyErrorDisplay } from "~/utils/httpErrors";
import { permissionDeniedMessage } from "~/utils/permissionDenied";
import { LinkButton } from "./primitives/Buttons";
import { Header1 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import { PermissionDenied } from "./PermissionDenied";
import { TriggerRotatingLogo } from "./TriggerRotatingLogo";
import { type ReactNode } from "react";

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

// A failed `authorization` check (or `throwPermissionDenied`) throws a 403
// that bubbles to the nearest route ErrorBoundary. Every layout boundary
// renders through here, so handling it once means a gated route only has to
// declare `authorization` to get the permission panel: no per-route boundary.
const permission = isRouteErrorResponse(error) ? permissionDeniedMessage(error.data) : null;
if (permission) {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<div className="w-full max-w-md">
<PermissionDenied message={permission} />
</div>
</div>
);
}

return (
<>
{isRouteErrorResponse(error) ? (
Expand Down
27 changes: 27 additions & 0 deletions apps/webapp/app/components/PermissionDenied.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NoSymbolIcon } from "@heroicons/react/20/solid";
import React from "react";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { organizationRolesPath } from "~/utils/pathBuilder";
import { LinkButton } from "./primitives/Buttons";
import { InfoPanel } from "./primitives/InfoPanel";

export function PermissionDenied({ message }: { message: React.ReactNode }) {
const organization = useOptionalOrganization();

return (
<InfoPanel
icon={NoSymbolIcon}
iconClassName="text-text-dimmed"
title="Permission denied"
accessory={
organization ? (
<LinkButton to={organizationRolesPath(organization)} variant="secondary/small">
View roles
</LinkButton>
) : undefined
}
>
{message}
</InfoPanel>
);
}
36 changes: 36 additions & 0 deletions apps/webapp/app/components/primitives/PermissionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { forwardRef, type ReactNode } from "react";
import { Button } from "./Buttons";

export const DEFAULT_NO_PERMISSION_TOOLTIP = "You don't have permission to do this";

type PermissionButtonProps = React.ComponentProps<typeof Button> & {
/** Server-computed flag (see `checkPermissions`). When false the button is disabled with a tooltip. */
hasPermission: boolean;
noPermissionTooltip?: ReactNode;
};

/**
* A `Button` that disables itself and shows an explanatory tooltip when the
* user lacks permission. Display only — the server route builder's
* `authorization` block is the real gate. `Button` already renders its
* `tooltip` while disabled (it wraps the disabled button in a hoverable span),
* so we reuse that path.
*/
export const PermissionButton = forwardRef<HTMLButtonElement, PermissionButtonProps>(
({ hasPermission, noPermissionTooltip, disabled, tooltip, ...props }, ref) => {
if (hasPermission) {
return <Button ref={ref} disabled={disabled} tooltip={tooltip} {...props} />;
}

return (
<Button
ref={ref}
{...props}
disabled
tooltip={noPermissionTooltip ?? DEFAULT_NO_PERMISSION_TOOLTIP}
/>
);
}
);

PermissionButton.displayName = "PermissionButton";
43 changes: 43 additions & 0 deletions apps/webapp/app/components/primitives/PermissionLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { type ReactNode } from "react";
import { cn } from "~/utils/cn";
import { ButtonContent, type ButtonContentPropsType, LinkButton } from "./Buttons";
import { SimpleTooltip } from "./Tooltip";
import { DEFAULT_NO_PERMISSION_TOOLTIP } from "./PermissionButton";

type PermissionLinkProps = React.ComponentProps<typeof LinkButton> & {
/** Server-computed flag (see `checkPermissions`). When false the link is disabled with a tooltip. */
hasPermission: boolean;
noPermissionTooltip?: ReactNode;
};

/**
* A `LinkButton` that disables itself and shows an explanatory tooltip when the
* user lacks permission. Display only — the server route builder's
* `authorization` block is the real gate. Unlike `Button`, `LinkButton` has no
* tooltip support and renders a `pointer-events-none` element when disabled
* (which can't be hovered), so the denied state renders a `SimpleTooltip`
* around a non-interactive `ButtonContent` instead — the same pattern the team
* settings page uses for its gated controls.
*/
export function PermissionLink({
hasPermission,
noPermissionTooltip,
...props
}: PermissionLinkProps) {
if (hasPermission) {
return <LinkButton {...props} />;
}

return (
<SimpleTooltip
button={
<ButtonContent
{...(props as ButtonContentPropsType)}
className={cn(props.className, "cursor-not-allowed opacity-50")}
/>
}
content={noPermissionTooltip ?? DEFAULT_NO_PERMISSION_TOOLTIP}
disableHoverableContent
/>
);
}
149 changes: 101 additions & 48 deletions apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ type RunsTableProps = {
showTopBorder?: boolean;
stickyHeader?: boolean;
childrenStatusesBasePath?: string;
/**
* Display-only write:runs flags from the caller's loader. Default true so
* callers that don't pass them (and OSS, where the ability is permissive)
* keep the controls enabled. The cancel/replay action routes enforce
* write:runs regardless.
*/
canCancelRuns?: boolean;
canReplayRuns?: boolean;
};

export function TaskRunsTable({
Expand All @@ -95,6 +103,8 @@ export function TaskRunsTable({
showTopBorder = true,
stickyHeader = false,
childrenStatusesBasePath,
canCancelRuns = true,
canReplayRuns = true,
}: RunsTableProps) {
const regions = useRegions();
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
Expand Down Expand Up @@ -512,7 +522,12 @@ export function TaskRunsTable({
{run.tags.map((tag) => <RunTag key={tag} tag={tag} />) || "–"}
</div>
</TableCell>
<RunActionsCell run={run} path={path} />
<RunActionsCell
run={run}
path={path}
canCancelRuns={canCancelRuns}
canReplayRuns={canReplayRuns}
/>
</TableRow>
);
})
Expand All @@ -530,7 +545,17 @@ export function TaskRunsTable({
);
}

function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
function RunActionsCell({
run,
path,
canCancelRuns,
canReplayRuns,
}: {
run: NextRunListItem;
path: string;
canCancelRuns: boolean;
canReplayRuns: boolean;
}) {
const location = useLocation();

if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
Expand All @@ -546,57 +571,85 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
leadingIconClassName="text-blue-500"
title="View run"
/>
{run.isCancellable && (
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
>
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
{run.isCancellable &&
(canCancelRuns ? (
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
{run.isReplayable && (
<Dialog>
<DialogTrigger
asChild
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
) : (
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
disabled
tooltip="You don't have permission to cancel runs"
>
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-success"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
Cancel run
</Button>
))}
{run.isReplayable &&
(canReplayRuns ? (
<Dialog>
<DialogTrigger
asChild
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
>
Replay run…
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-success"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
>
Replay run…
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={`${location.pathname}${location.search}`}
/>
</Dialog>
) : (
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-success"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
disabled
tooltip="You don't have permission to replay runs"
>
Replay run…
</Button>
))}
</>
}
hiddenButtons={
<>
{run.isCancellable && (
{run.isCancellable && canCancelRuns && (
<SimpleTooltip
button={
<Dialog>
Expand All @@ -617,10 +670,10 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
disableHoverableContent
/>
)}
{run.isCancellable && run.isReplayable && (
{run.isCancellable && canCancelRuns && run.isReplayable && canReplayRuns && (
<div className="mx-0.5 h-6 w-px bg-grid-dimmed" />
)}
{run.isReplayable && (
{run.isReplayable && canReplayRuns && (
<SimpleTooltip
button={
<Dialog>
Expand Down
Loading