feat(webapp): enforce RBAC permissions on run, prompt, member, and billing routes#3948
Conversation
|
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis change adds RBAC-based permission enforcement across dashboard routes. A new ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
92775e5 to
842d25d
Compare
842d25d to
c99e530
Compare
@trigger.dev/build
trigger.dev
@trigger.dev/core
@trigger.dev/python
@trigger.dev/react-hooks
@trigger.dev/redis-worker
@trigger.dev/rsc
@trigger.dev/schema-to-json
@trigger.dev/sdk
commit: |
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.
7bbd831 to
625c8fe
Compare
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 RBAC 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 RBAC 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 RBAC 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.
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.
… scope The dashboard route builder resolves an org/project scope in each route context and runs the authorization check against it. When the scope could not be resolved the check evaluated an unscoped ability, so it silently became a no-op for a missing org. The builder now treats a scoped authorization block with no resolved org or project as a denial (requireSuper stays global), so the check can never pass unscoped. Add a shared resolveOrgIdFromSlug that reads the replica first and falls back to the primary on a miss, and use it across the dashboard routes, so replica lag never leaves a real org unresolved.
Combine the two RBAC server-changes notes into a single entry that lists the new permission boundaries. Reword the run cancel/replay comments to refer to the RBAC plugin generically.
8adbd3d to
fd6d0bf
Compare
Add writableEnvironmentIds to the env vars page loader data type so the create form sees write access rather than an undefined field. Guard the prompt action on a resolved org before its per-intent ability checks, since it has no top-level authorization block and so skips the builder fail-closed.
Member removal looked up and deleted an orgMember by its globally unique id without binding it to the resolved organization, so a manager in one org could remove members of another by submitting a foreign id. Scope the lookup and delete to the org at both the route and the model layer, and reject a foreign id. A run replay can target a different environment, but the user-supplied override was forwarded to the trigger service without scoping, so a run could be created in another tenant environment. Validate the override belongs to the source run project before triggering. When the inviter has no resolvable role, the role-ladder check returned true and offered every role including the highest. Fail closed instead: the invite still proceeds, but assigning an explicit role is refused.
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:
How
Each affected route now goes through the
dashboardLoader/dashboardActionroute builders with anauthorizationblock 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, andPermissionButton/PermissionLinkdisable 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;checkPermissionshas unit tests.