Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0a70472
feat(webapp): add permission-gating primitives
matt-aitken Jun 11, 2026
14404fe
fix(webapp): enforce write:runs on single-run cancel and replay actions
matt-aitken Jun 11, 2026
670cd58
Remove the Create role UI for now
matt-aitken Jun 11, 2026
62285e7
fix(webapp): enforce write:runs on bulk action create and abort
matt-aitken Jun 11, 2026
34a41c2
fix(webapp): gate run-detail Replay and Cancel buttons on write:runs
matt-aitken Jun 11, 2026
fc6b13b
fix(webapp): make RBAC role assignment on invite accept non-fatal
matt-aitken Jun 12, 2026
a8ad641
fix(webapp): enforce write:prompts / update:prompts on prompt detail …
matt-aitken Jun 12, 2026
e5fa598
fix(webapp): enforce manage:members on invite/resend/revoke routes + UI
matt-aitken Jun 12, 2026
ac16eee
fix(webapp): enforce manage:billing on billing/plan/portal routes
matt-aitken Jun 12, 2026
e1aaaa7
fix(webapp): gate TaskRunsTable row menu + runs-index/errors bulk con…
matt-aitken Jun 15, 2026
942b429
chore(webapp): add server-changes note for RBAC route permission enfo…
matt-aitken Jun 15, 2026
9d0b386
fix(webapp): restore manage:billing enforcement on billing + billing-…
matt-aitken Jun 15, 2026
2d95c30
fix(webapp): enforce write:deployments on rollback/promote/cancel rou…
matt-aitken Jun 15, 2026
02ff349
fix(webapp): enforce write:github on the GitHub integration route
matt-aitken Jun 15, 2026
21072b0
fix(webapp): gate GitHub integration UI + install entry on write:github
matt-aitken Jun 15, 2026
969faf5
fix(webapp): enforce write:vercel on Vercel integration routes + UI
matt-aitken Jun 15, 2026
507a000
fix(webapp): disable seat purchase without manage:billing
matt-aitken Jun 16, 2026
1fb673d
feat(webapp): gate API keys page on env-tier read access
matt-aitken Jun 16, 2026
a7e0aab
fix(webapp): show a permission panel instead of redirecting on gated …
matt-aitken Jun 16, 2026
def4d73
feat(webapp): roles page full-height sticky table + current role + en…
matt-aitken Jun 16, 2026
8e8906f
feat(webapp): enforce env var permissions on the dashboard
matt-aitken Jun 16, 2026
f6c3387
feat(webapp): enforce env var permissions on the API routes
matt-aitken Jun 16, 2026
319c50b
docs(webapp): drop tracker reference from invite role comment
matt-aitken Jun 16, 2026
6b6d008
feat(webapp): enforce env-tier access on environment credential endpo…
matt-aitken Jun 16, 2026
f0d74c3
chore(webapp): add server-changes note for RBAC permission enforcement
matt-aitken Jun 16, 2026
d177622
fix(webapp): show a permission panel on the integrations page for res…
matt-aitken Jun 17, 2026
665c089
refactor(webapp): render permission-denied via an error boundary
matt-aitken Jun 17, 2026
09b21f6
refactor(webapp): render the permission panel via the route error bou…
matt-aitken Jun 17, 2026
2a68297
Super admin redirects
matt-aitken Jun 17, 2026
c133aea
fix(webapp): harden RBAC org scoping and write-tier gating
matt-aitken Jun 17, 2026
c8393c8
fix(webapp): surface promote/rollback action errors on the form
matt-aitken Jun 17, 2026
b0843d5
fix(webapp): fail closed when a scoped authorization check has no org…
matt-aitken Jun 18, 2026
fd6d0bf
chore(webapp): collapse RBAC server-changes note and reword comments
matt-aitken Jun 18, 2026
bbf86c8
fix(webapp): close two RBAC scope gaps from review
matt-aitken Jun 18, 2026
05cd473
fix(webapp): scope member removal and run replay to the caller org
matt-aitken Jun 18, 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. 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.
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