Skip to content

feat(webapp): enforce RBAC permissions on run, prompt, member, and billing routes#3948

Open
matt-aitken wants to merge 31 commits into
mainfrom
feature/tri-10644-rbac-developer-enforcement
Open

feat(webapp): enforce RBAC permissions on run, prompt, member, and billing routes#3948
matt-aitken wants to merge 31 commits into
mainfrom
feature/tri-10644-rbac-developer-enforcement

Conversation

@matt-aitken

@matt-aitken matt-aitken commented Jun 15, 2026

Copy link
Copy Markdown
Member

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.

@changeset-bot

changeset-bot Bot commented Jun 15, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 43bdfd6

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This change adds RBAC-based permission enforcement across dashboard routes. A new checkPermissions server utility maps a keyed set of PermissionCheck definitions against an RbacAbility instance to produce boolean permission flags. Two new UI primitives, PermissionButton and PermissionLink, wrap existing button components to disable and show a tooltip when hasPermission is false. Multiple routes (run cancel/replay, bulk actions, prompt mutations, member invite/resend/revoke, deployment actions, GitHub integration, Vercel integration, and billing) are migrated from standalone Remix loader/action exports to dashboardLoader/dashboardAction wrappers with explicit authorization configurations and resolveOrgIdFromSlug/resolveRunOrganizationId/resolveOrgIdFromProjectId context helpers. The acceptInvite RBAC role assignment is made best-effort via try/catch. Permission flags are computed in loaders and passed through to UI components, which conditionally disable interactive controls. Default behavior when RBAC is not configured remains permissive.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/tri-10644-rbac-developer-enforcement

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

devin-ai-integration[bot]

This comment was marked as resolved.

@matt-aitken matt-aitken force-pushed the feature/tri-10644-rbac-developer-enforcement branch from 92775e5 to 842d25d Compare June 15, 2026 11:10
devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@matt-aitken matt-aitken force-pushed the feature/tri-10644-rbac-developer-enforcement branch from 842d25d to c99e530 Compare June 15, 2026 15:00
@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@625c8fe

trigger.dev

npm i https://pkg.pr.new/trigger.dev@625c8fe

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@625c8fe

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@625c8fe

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@625c8fe

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@625c8fe

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@625c8fe

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@625c8fe

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@625c8fe

commit: 625c8fe

devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Add checkPermissions(ability, checks) which maps a set of action/resource
checks to a boolean record using the injected ability, so loaders can
compute display-only permission flags server-side and pass them to the
client. Add PermissionButton and PermissionLink wrappers that disable the
underlying control and show an explanatory tooltip when a server-computed
hasPermission flag is false. No permission logic ships to the client; the
route builder authorization block remains the security boundary.
Wrap the dashboard cancel and replay resource-route actions in
dashboardAction with an authorization block (write:runs), resolving the
run's organization for the auth scope from Postgres with a mollifier
buffer fallback. The existing org-membership queries are retained as the
tenancy boundary; the RBAC check layers on top and only enforces under the
enterprise plugin.
Migrate the bulk-action create/replay route and the bulk-action abort
route to dashboardLoader/dashboardAction with a write:runs authorization
block, resolving the org for the auth scope from the URL slug. Surface
canCreateBulkAction and canAbort display flags via checkPermissions and
gate the inspector's Cancel/Replay trigger and the Abort button. Tenancy
queries (findProjectBySlug/findEnvironmentBySlug) are unchanged.
Surface write:runs as canReplayRun/canCancelRun from the run-detail loader
(via the injected RBAC ability) and disable the Replay and Cancel controls
with an explanatory tooltip when the role lacks it. Display only; the
cancel/replay action routes are the enforcement boundary.
The setUserRole call in acceptInvite ran outside a try/catch, so a thrown
error from the RBAC plugin escaped and turned the whole invite-accept into
a 400 (the membership was already created in the transaction). Wrap it so
both a returned {ok:false} and a thrown error are logged, including the
stack, and never block joining the org.
…route + UI

Migrate the prompt detail action to dashboardAction and check the right
permission per intent: promote -> update:prompts, create/edit/remove/
reactivate override -> write:prompts. Surface canPromote / canWritePrompts
display flags from the loader (via the injected ability) and gate the
Promote, Reactivate, Create override, Edit, and Remove buttons. Tenancy
queries unchanged; permissive in OSS, enforced under the enterprise plugin.
Migrate the invite, invite-resend, and invite-revoke routes to
dashboardLoader/dashboardAction with a manage:members authorization block.
The resend/revoke routes have no URL params, so the org for the auth scope
is resolved from the form body (read via a cloned request) — from the
invite's organization (resend) or the slug field (revoke). Gate the
Resend/Revoke buttons on the team page with the existing canManageMembers
flag. Existing tenancy/inviter checks in the model layer are unchanged.
Migrate the billing settings, standalone select-plan page, select-plan
mutation, billing-alerts (loader + action), and Stripe customer-portal
routes to dashboardLoader/dashboardAction with a manage:billing
authorization block, resolving the org for the auth scope from the URL
slug. The isManagedCloud guards and org-membership queries are unchanged;
gating the page loaders means denied roles can't reach the billing UI at
all. Permissive in OSS, enforced under the enterprise plugin.
…trols on write:runs

Thread canCancelRuns/canReplayRuns (default true) through TaskRunsTable to
RunActionsCell: disable + tooltip the Cancel/Replay popover items and hide
the redundant hover icons when denied. The runs-index and errors loaders
compute the flags from the injected ability; gate the index Bulk action
button + r/c shortcuts and the errors Bulk replay link accordingly.
Display only; the action routes enforce write:runs. Permissive in OSS.
…alerts routes

These two routes reverted to raw loaders/actions when main's changes were
taken during a merge conflict. Re-apply the dashboardLoader/dashboardAction
migration with a manage:billing authorization block on top of main's current
code (which added the showSelfServe branching), keeping the isManagedCloud
guard and membership queries.
…tes + UI

Migrate the three deployment resource-route actions to dashboardAction with
a write:deployments authorization block, resolving the org for the auth scope
from the project. Surface canWriteDeployments from the deployments loader and
gate the Rollback/Promote/Cancel row-menu items (disable + tooltip when
denied). Tenancy/membership queries unchanged; permissive in OSS.
Migrate the GitHub settings resource-route action (connect-repo /
disconnect-repo / update-git-settings) to dashboardAction with a write:github
authorization block, and surface canManageGithub from the loader for UI
gating. Project membership checks unchanged; permissive in OSS.
Gate the GitHub settings panel controls (Install / Connect repo / Disconnect /
Save) on the canManageGithub flag, and wrap the GitHub app install entry route
in dashboardLoader with a write:github authorization block (org resolved from
the org_slug query param). Membership queries unchanged; permissive in OSS.
Migrate the Vercel settings resource action, the Vercel app install entry,
and the org-level uninstall action to dashboardLoader/dashboardAction with a
write:vercel authorization block. Surface canManageVercel from the loaders and
gate the Connect / Install / Reconnect / Disconnect / Save / Remove controls.
Membership queries unchanged; permissive in OSS.
The team page's seat purchase button now disables itself with an
explanatory tooltip when the current role can't manage billing, matching
the server-side check the action already enforces.
Add a reusable PermissionDenied panel and use it on the API keys page:
when a role can't read a given environment's secret key (e.g. deployed
environments for a restricted role), the secret is withheld server-side
and the page renders the panel instead. Regenerating an API key is gated
the same way, enforced on the POST so a disabled button isn't the only
guard.
…pages

The billing, billing alerts, and invite pages hard-redirected to the org
home when the current role lacked access, which looked like a broken link.
They now render the page shell with a PermissionDenied panel (and a link to
view roles), and withhold their data server-side when access is denied. The
matching mutations stay enforced independently.
…v chips

The roles comparison table now fills the remaining height with a sticky
header and scrolls internally. A line under the description states the
viewer's own role, and env-tier permission conditions render as environment
chips for the environments they apply to instead of raw text.
The environment variables list now withholds values for environments the
current role can't read and shows a permission-denied state in their place.
The create dialog disables the environment targets the role can't write
(with a tooltip) and its action rejects those targets server-side. The
permission-denied states use the no-entry icon.
The environment variable API routes now apply the caller's role to the
targeted environment tier when authenticated with a personal access token,
so a restricted role can't read or write deployed env vars via the API.
Environment API keys are scoped to a single environment already, so they
are unaffected.
…ints

The endpoints that hand a personal access token an environment's secret
key or a key-signed JWT now apply the caller's role for that environment
tier. A restricted role can't pull deployed-environment credentials, which
is what stops it deploying via the CLI (deploy authenticates with the
environment secret key). Environment API keys are scoped to a single
environment already, so they are unaffected.
…tricted roles

The project integrations page (Git, Vercel, and build settings) rendered an
empty page for roles that can't manage integrations. It now shows a
permission-denied panel, and the build-settings action is gated server-side.
Add throwPermissionDenied + a PermissionDeniedBoundary so a gated loader can
just throw, and the route's error boundary renders the panel (falling back to
the normal error display otherwise). Switch the integrations page to this:
the loader throws when the role can't manage integrations and the page
component only renders for allowed users, dropping the flag-and-split
boilerplate.
…ndary

A failed `authorization` check in dashboardLoader/dashboardAction now throws a
403 that the shared RouteErrorDisplay turns into the permission panel, so a
gated route only needs to declare `authorization`: no per-route error boundary
or manual denial UI. Boundaries on the env and settings layouts keep the side
nav visible alongside the panel. Billing and billing alerts now use the same
declarative authorization instead of a hand-rolled flag.
@matt-aitken matt-aitken force-pushed the feature/tri-10644-rbac-developer-enforcement branch from 7bbd831 to 625c8fe Compare June 17, 2026 17:02
Resolve the RBAC organization from the primary so the role check is never
evaluated without an org scope under replica lag: run cancel/replay fall back
to the primary when the replica and buffer both miss, and the bulk-action
routes read the org from the primary directly. Pin the buffered replay
environment lookup to the buffer entry org so a malformed entry cannot resolve
an environment in another org.

Gate the seat-purchase modal on billing permission on the invite page, and gate
environment-variable creation on write access rather than read access.
The deployment promote and rollback actions assigned errors to a runParam
field that does not exist in their form schema (copied from the run routes),
so Conform never rendered them. Use the root-level error key instead.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +79 to +81
const ctx = (
options.context ? await options.context(parsedParams, request) : ({} as TContext)
) as TContext;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Unscoped RBAC when org resolution returns null in context callbacks

Many routes follow a pattern where the context callback resolves an org ID and returns {} if the org doesn't exist. When this happens, rbac.authenticateSession runs with no organizationId, producing an unscoped ability. With the enterprise RBAC plugin, the unscoped ability behavior determines whether the authorization check passes or fails. In OSS (no plugin), the ability is always permissive, so the check passes regardless.

This is safe in practice because every handler independently validates the org's existence and the user's membership before performing any operation — a missing org always results in a 404 or redirect before any side effects. However, it means the authorization check is effectively a no-op for non-existent orgs, which is worth understanding for future auditing.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant