diff --git a/.server-changes/rbac-permission-enforcement.md b/.server-changes/rbac-permission-enforcement.md new file mode 100644 index 00000000000..1d72b0d7b3f --- /dev/null +++ b/.server-changes/rbac-permission-enforcement.md @@ -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. diff --git a/apps/webapp/app/components/ErrorDisplay.tsx b/apps/webapp/app/components/ErrorDisplay.tsx index 5787a2edbac..e1e0d3311b4 100644 --- a/apps/webapp/app/components/ErrorDisplay.tsx +++ b/apps/webapp/app/components/ErrorDisplay.tsx @@ -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"; @@ -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 ( +
+
+ +
+
+ ); + } + return ( <> {isRouteErrorResponse(error) ? ( diff --git a/apps/webapp/app/components/PermissionDenied.tsx b/apps/webapp/app/components/PermissionDenied.tsx new file mode 100644 index 00000000000..b00e70789b9 --- /dev/null +++ b/apps/webapp/app/components/PermissionDenied.tsx @@ -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 ( + + View roles + + ) : undefined + } + > + {message} + + ); +} diff --git a/apps/webapp/app/components/primitives/PermissionButton.tsx b/apps/webapp/app/components/primitives/PermissionButton.tsx new file mode 100644 index 00000000000..22b6baa354c --- /dev/null +++ b/apps/webapp/app/components/primitives/PermissionButton.tsx @@ -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 & { + /** 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( + ({ hasPermission, noPermissionTooltip, disabled, tooltip, ...props }, ref) => { + if (hasPermission) { + return - - - - )} - {run.isReplayable && ( - - + Cancel run + + + + + ) : ( + - - - - )} + + + + + ) : ( + + ))} } hiddenButtons={ <> - {run.isCancellable && ( + {run.isCancellable && canCancelRuns && ( @@ -617,10 +670,10 @@ function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) { disableHoverableContent /> )} - {run.isCancellable && run.isReplayable && ( + {run.isCancellable && canCancelRuns && run.isReplayable && canReplayRuns && (
)} - {run.isReplayable && ( + {run.isReplayable && canReplayRuns && ( diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index b88fc7e11c0..e88f5a5ccf0 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -73,15 +73,24 @@ export async function removeTeamMember({ throw new Error("User does not have access to this organization"); } - return prisma.orgMember.delete({ - where: { - id: memberId, - }, + // Scope the target to this org. A member id is a globally unique key, so + // deleting by id alone would remove members of other orgs; bind it to the + // resolved org and reject a foreign id. + const member = await prisma.orgMember.findFirst({ + where: { id: memberId, organizationId: org.id }, include: { organization: true, user: true, }, }); + + if (!member) { + throw new Error("Member not found in this organization"); + } + + await prisma.orgMember.delete({ where: { id: member.id } }); + + return member; } export async function inviteMembers({ @@ -227,19 +236,35 @@ export async function acceptInvite({ }; }); - // If the invite carried an explicit RBAC role. Errors are logged, not fatal. + // If the invite carried an explicit RBAC role, assign it. Best-effort: the + // invite is already consumed and membership created above, so a failure here + // — a returned {ok:false} or a thrown error from the plugin — must not block + // joining the org. Swallow and log either way; without the catch a plugin + // throw escapes and turns the whole invite-accept into a 400. if (result.rbacRoleId) { - const roleResult = await rbac.setUserRole({ - userId: user.id, - organizationId: result.organization.id, - roleId: result.rbacRoleId, - }); - if (!roleResult.ok) { - logger.error("acceptInvite: skipped RBAC role assignment", { + try { + const roleResult = await rbac.setUserRole({ + userId: user.id, + organizationId: result.organization.id, + roleId: result.rbacRoleId, + }); + if (!roleResult.ok) { + logger.error("acceptInvite: skipped RBAC role assignment", { + organizationId: result.organization.id, + userId: user.id, + rbacRoleId: result.rbacRoleId, + reason: roleResult.error, + }); + } + } catch (error) { + logger.error("acceptInvite: RBAC role assignment threw", { organizationId: result.organization.id, userId: user.id, rbacRoleId: result.rbacRoleId, - reason: roleResult.error, + error: + error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : String(error), }); } } diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 14315dd337c..6ee9a40d6da 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -9,7 +9,7 @@ import type { import { customAlphabet } from "nanoid"; import { generate } from "random-words"; import slug from "slug"; -import { prisma, type PrismaClientOrTransaction } from "~/db.server"; +import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { featuresForUrl } from "~/features.server"; import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; @@ -18,6 +18,28 @@ export type { Organization }; const nanoid = customAlphabet("1234567890abcdef", 4); +/** + * Resolve an organization id from its slug for use as an RBAC auth scope. + * Reads the replica first (the common case) and falls back to the primary on a + * miss, so replica lag never leaves a real org unresolved, which the dashboard + * route builder treats as an unauthorized request. + */ +export async function resolveOrgIdFromSlug(slug: string): Promise { + const fromReplica = await $replica.organization.findFirst({ + where: { slug }, + select: { id: true }, + }); + if (fromReplica) { + return fromReplica.id; + } + + const fromPrimary = await prisma.organization.findFirst({ + where: { slug }, + select: { id: true }, + }); + return fromPrimary?.id ?? null; +} + export async function createOrganization( { title, diff --git a/apps/webapp/app/routes/_app.github.install/route.tsx b/apps/webapp/app/routes/_app.github.install/route.tsx index 42d68e5bec1..a037ae386b9 100644 --- a/apps/webapp/app/routes/_app.github.install/route.tsx +++ b/apps/webapp/app/routes/_app.github.install/route.tsx @@ -1,9 +1,9 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "remix-typedjson"; import { z } from "zod"; import { $replica } from "~/db.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { createGitHubAppInstallSession } from "~/services/gitHubSession.server"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { newOrganizationPath } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; import { sanitizeRedirectPath } from "~/utils"; @@ -15,38 +15,52 @@ const QuerySchema = z.object({ }), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const searchParams = new URL(request.url).searchParams; - const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); +export const loader = dashboardLoader( + { + // The org for the auth scope comes from the `org_slug` query param. + context: async (_params, request) => { + const orgSlug = new URL(request.url).searchParams.get("org_slug"); + if (!orgSlug) return {}; + const organizationId = await resolveOrgIdFromSlug(orgSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "github" } }, + // Redirect endpoint (no UI): keep redirecting on denial rather than + // throwing the permission panel. + unauthorizedRedirect: "/", + }, + async ({ request, user }) => { + const searchParams = new URL(request.url).searchParams; + const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); - if (!parsed.success) { - logger.warn("GitHub App installation redirect with invalid params", { - searchParams, - error: parsed.error, - }); - throw redirect("/"); - } + if (!parsed.success) { + logger.warn("GitHub App installation redirect with invalid params", { + searchParams, + error: parsed.error, + }); + throw redirect("/"); + } - const { org_slug, redirect_to } = parsed.data; - const user = await requireUser(request); + const { org_slug, redirect_to } = parsed.data; - const org = await $replica.organization.findFirst({ - where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, - orderBy: { createdAt: "desc" }, - select: { - id: true, - }, - }); + const org = await $replica.organization.findFirst({ + where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + }, + }); - if (!org) { - throw redirect(newOrganizationPath()); - } + if (!org) { + throw redirect(newOrganizationPath()); + } - const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to); + const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to); - return redirect(url, { - headers: { - "Set-Cookie": cookieHeader, - }, - }); -}; + return redirect(url, { + headers: { + "Set-Cookie": cookieHeader, + }, + }); + } +); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index f77c19ffbdd..52dbdda680b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -6,13 +6,11 @@ import { LockOpenIcon, UserPlusIcon, } from "@heroicons/react/20/solid"; -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; import { Fragment, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import simplur from "simplur"; -import invariant from "tiny-invariant"; import { z } from "zod"; import { MainCenteredContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -31,10 +29,11 @@ import { env } from "~/env.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { inviteMembers } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; import { scheduleEmail } from "~/services/scheduleEmail.server"; import { rbac } from "~/services/rbac.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder"; import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route"; @@ -42,55 +41,66 @@ const Params = z.object({ organizationSlug: z.string(), }); -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug } = Params.parse(params); - - const organization = await $replica.organization.findFirst({ - where: { slug: organizationSlug }, - select: { id: true }, - }); +export const loader = dashboardLoader( + { + params: Params, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { + action: "manage", + resource: { type: "members" }, + message: "With your current role, you can't invite team members.", + }, + }, + async ({ user, context, ability }) => { + const organizationId = context.organizationId; + if (!organizationId) { + throw new Response("Not Found", { status: 404 }); + } + const userId = user.id; - if (!organization) { - throw new Response("Not Found", { status: 404 }); - } + const presenter = new TeamPresenter(); + const result = await presenter.call({ + userId, + organizationId, + }); - const presenter = new TeamPresenter(); - const result = await presenter.call({ - userId, - organizationId: organization.id, - }); + if (!result) { + throw new Response("Not Found", { status: 404 }); + } - if (!result) { - throw new Response("Not Found", { status: 404 }); - } + // Inviter's own role drives the "below their level" filter on the + // dropdown. Plus assignable role IDs already encode the org's plan + // tier — the intersection is what we offer. + const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ + rbac.getUserRole({ userId, organizationId }), + rbac.getAssignableRoleIds(organizationId), + rbac.systemRoles(organizationId), + ]); - // Inviter's own role drives the "below their level" filter on the - // dropdown. Plus assignable role IDs already encode the org's plan - // tier — the intersection is what we offer. - const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ - rbac.getUserRole({ userId, organizationId: organization.id }), - rbac.getAssignableRoleIds(organization.id), - rbac.systemRoles(organization.id), - ]); + // Build the dropdown's offerable set server-side: roles that are + // (a) assignable on the current plan AND (b) at or below the + // inviter's own level. The client just renders these — it doesn't + // need to know about the system-role catalogue or the ladder. + const assignableSet = new Set(assignableRoleIds); + const offerableRoleIds = systemRoles + ? result.roles + .filter( + (r) => + assignableSet.has(r.id) && isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id) + ) + .map((r) => r.id) + : []; - // Build the dropdown's offerable set server-side: roles that are - // (a) assignable on the current plan AND (b) at or below the - // inviter's own level. The client just renders these — it doesn't - // need to know about the system-role catalogue or the ladder. - const assignableSet = new Set(assignableRoleIds); - const offerableRoleIds = systemRoles - ? result.roles - .filter( - (r) => - assignableSet.has(r.id) && - isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id) - ) - .map((r) => r.id) - : []; + // Buying seats is a billing operation: surface whether this user can, so + // the purchase modal disables its trigger (the team action enforces it). + const canManageBilling = ability.can("manage", { type: "billing" }); - return typedjson({ ...result, offerableRoleIds }); -}; + return typedjson({ ...result, offerableRoleIds, canManageBilling }); + } +); // Sentinel for "no RBAC role attached to invite" — the runtime // fallback will derive a role from the legacy OrgMember.role write at @@ -103,7 +113,7 @@ const NO_RBAC_ROLE = "__no_rbac_role__"; // first), so array index drives the ladder — earlier index = higher // rank. Plan-tier filtering happens separately via assignableRoleIds; // the ladder is the absolute hierarchy. Custom roles aren't in the -// table and are refused (TRI-8747's follow-up will handle them). +// ladder yet, so they're refused for now. type LadderRole = { id: string }; function buildRoleLevel(roles: ReadonlyArray): Record { @@ -122,12 +132,11 @@ function isAtOrBelow( inviterRoleId: string | null, invitedRoleId: string ): boolean { - // No RBAC role on inviter (e.g. the runtime fallback couldn't derive - // one) → fall back to the legacy OrgMember.role check the calling - // code already enforces. Allow the invite to proceed; the action - // would have already failed earlier if the inviter wasn't allowed - // to invite at all. - if (!inviterRoleId) return true; + // No resolvable role for the inviter → fail closed: we can't confirm a + // target role is at or below an unknown level, so refuse it. The invite + // itself still proceeds (it's gated by manage:members); only assigning an + // explicit role is refused, and the picker offers nothing in this case. + if (!inviterRoleId) return false; const level = buildRoleLevel(roles); const inviter = level[inviterRoleId]; const invited = level[invitedRoleId]; @@ -153,101 +162,101 @@ const schema = z.object({ rbacRoleId: z.string().optional(), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug } = params; - invariant(organizationSlug, "organizationSlug is required"); - - const formData = await request.formData(); - const submission = parse(formData, { schema }); +export const action = dashboardAction( + { + params: Params, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "manage", resource: { type: "members" } }, + }, + async ({ request, params, user }) => { + const userId = user.id; + const { organizationSlug } = params; - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + const formData = await request.formData(); + const submission = parse(formData, { schema }); - // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown - // role → don't pass one through; the runtime fallback handles it. - // Validation: the chosen role must be in the org's assignable set - // (plan-tier) and at or below the inviter's own level. - let resolvedRbacRoleId: string | null = null; - const submittedRbacRoleId = submission.value.rbacRoleId; - if ( - submittedRbacRoleId && - submittedRbacRoleId !== NO_RBAC_ROLE - ) { - const org = await $replica.organization.findFirst({ - where: { slug: organizationSlug }, - select: { id: true }, - }); - if (!org) { - return json({ errors: { body: "Organization not found" } }, { status: 404 }); + if (!submission.value || submission.intent !== "submit") { + return json(submission); } - const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ - rbac.getUserRole({ userId, organizationId: org.id }), - rbac.getAssignableRoleIds(org.id), - rbac.systemRoles(org.id), - ]); - if (!systemRoles) { - // No plugin installed but the form somehow submitted a role id — - // ignore it (fall through to legacy behaviour rather than 400). - resolvedRbacRoleId = null; - } else { - const assignable = new Set(assignableRoleIds); - if (!assignable.has(submittedRbacRoleId)) { - return json( - { errors: { body: "You can't invite someone with this role on your current plan" } }, - { status: 400 } - ); + + // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown + // role → don't pass one through; the runtime fallback handles it. + // Validation: the chosen role must be in the org's assignable set + // (plan-tier) and at or below the inviter's own level. + let resolvedRbacRoleId: string | null = null; + const submittedRbacRoleId = submission.value.rbacRoleId; + if (submittedRbacRoleId && submittedRbacRoleId !== NO_RBAC_ROLE) { + const org = await $replica.organization.findFirst({ + where: { slug: organizationSlug }, + select: { id: true }, + }); + if (!org) { + return json({ errors: { body: "Organization not found" } }, { status: 404 }); } - if ( - !isAtOrBelow( - systemRoles, - inviterRole?.id ?? null, - submittedRbacRoleId - ) - ) { - return json( - { errors: { body: "You can only invite members at or below your own role" } }, - { status: 403 } - ); + const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ + rbac.getUserRole({ userId, organizationId: org.id }), + rbac.getAssignableRoleIds(org.id), + rbac.systemRoles(org.id), + ]); + if (!systemRoles) { + // No plugin installed but the form somehow submitted a role id — + // ignore it (fall through to legacy behaviour rather than 400). + resolvedRbacRoleId = null; + } else { + const assignable = new Set(assignableRoleIds); + if (!assignable.has(submittedRbacRoleId)) { + return json( + { errors: { body: "You can't invite someone with this role on your current plan" } }, + { status: 400 } + ); + } + if (!isAtOrBelow(systemRoles, inviterRole?.id ?? null, submittedRbacRoleId)) { + return json( + { errors: { body: "You can only invite members at or below your own role" } }, + { status: 403 } + ); + } + resolvedRbacRoleId = submittedRbacRoleId; } - resolvedRbacRoleId = submittedRbacRoleId; } - } - try { - const invites = await inviteMembers({ - slug: organizationSlug, - emails: submission.value.emails, - userId, - rbacRoleId: resolvedRbacRoleId, - }); + try { + const invites = await inviteMembers({ + slug: organizationSlug, + emails: submission.value.emails, + userId, + rbacRoleId: resolvedRbacRoleId, + }); - for (const invite of invites) { - try { - await scheduleEmail({ - email: "invite", - to: invite.email, - orgName: invite.organization.title, - inviterName: invite.inviter.name ?? undefined, - inviterEmail: invite.inviter.email, - inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`, - }); - } catch (error) { - console.error("Failed to send invite email"); - console.error(error); + for (const invite of invites) { + try { + await scheduleEmail({ + email: "invite", + to: invite.email, + orgName: invite.organization.title, + inviterName: invite.inviter.name ?? undefined, + inviterEmail: invite.inviter.email, + inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`, + }); + } catch (error) { + console.error("Failed to send invite email"); + console.error(error); + } } - } - return redirectWithSuccessMessage( - organizationTeamPath(invites[0].organization), - request, - simplur`${submission.value.emails.length} member[|s] invited` - ); - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + return redirectWithSuccessMessage( + organizationTeamPath(invites[0].organization), + request, + simplur`${submission.value.emails.length} member[|s] invited` + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } } -}; +); export default function Page() { const { @@ -259,6 +268,7 @@ export default function Page() { planSeatLimit, roles, offerableRoleIds, + canManageBilling, } = useTypedLoaderData(); const [total, setTotal] = useState(limits.used); const organization = useOrganization(); @@ -274,9 +284,7 @@ export default function Page() { // Default to the lowest-tier offered role (the loader returns roles // in its allRoles order, which the plugin emits Owner→Member; the // last entry is the most restrictive). - const defaultRoleId = showRolePicker - ? offerable[offerable.length - 1].id - : NO_RBAC_ROLE; + const defaultRoleId = showRolePicker ? offerable[offerable.length - 1].id : NO_RBAC_ROLE; const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId); const [form, { emails }] = useForm({ @@ -316,6 +324,7 @@ export default function Page() { usedSeats={limits.used} maxQuota={maxSeatQuota} planSeatLimit={planSeatLimit} + canManageBilling={canManageBilling} triggerButton={} /> } @@ -386,9 +395,7 @@ export default function Page() { items={offerable} variant="tertiary/medium" dropdownIcon - text={(v) => - offerable.find((r) => r.id === v)?.name ?? "Pick a role" - } + text={(v) => offerable.find((r) => r.id === v)?.name ?? "Pick a role"} setValue={(next) => { if (typeof next === "string") setSelectedRoleId(next); }} @@ -402,8 +409,7 @@ export default function Page() { } - Invitees join with this role. They can be promoted later - from the Team page. + Invitees join with this role. They can be promoted later from the Team page. ) : null} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index 897687f4ec9..bfeb6ea9fee 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -1,6 +1,5 @@ import { BookOpenIcon } from "@heroicons/react/20/solid"; import { type MetaFunction } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { CodeBlock } from "~/components/code/CodeBlock"; @@ -16,6 +15,7 @@ import { PageBody, PageContainer, } from "~/components/layout/AppLayout"; +import { PermissionDenied } from "~/components/PermissionDenied"; import { Accordion, AccordionContent, @@ -32,8 +32,9 @@ import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import * as Property from "~/components/primitives/PropertyTable"; import { useOrganization } from "~/hooks/useOrganizations"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; @@ -45,33 +46,50 @@ export const meta: MetaFunction = () => { ]; }; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam, envParam } = EnvironmentParamSchema.parse(params); +export const loader = dashboardLoader( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // No hard authorization: anyone with project access can open the page. + // Reading the secret key is gated per environment tier below — a role + // that can't read this tier's keys gets the info panel, not the key. + }, + async ({ params, user, ability }) => { + const { projectParam, envParam } = params; - try { - const presenter = new ApiKeysPresenter(); - const { environment, hasVercelIntegration } = await presenter.call({ - userId, - projectSlug: projectParam, - environmentSlug: envParam, - }); + try { + const presenter = new ApiKeysPresenter(); + const { environment, hasVercelIntegration } = await presenter.call({ + userId: user.id, + projectSlug: projectParam, + environmentSlug: envParam, + }); - return typedjson({ - environment, - hasVercelIntegration, - }); - } catch (error) { - console.error(error); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", - }); + const canReadApiKeys = + !environment || ability.can("read", { type: "apiKeys", envType: environment.type }); + + return typedjson({ + // Never serialize the secret key to the client when the role can't + // read it for this environment tier. + environment: environment && !canReadApiKeys ? { ...environment, apiKey: "" } : environment, + hasVercelIntegration, + canReadApiKeys, + }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } } -}; +); export default function Page() { - const { environment, hasVercelIntegration } = useTypedLoaderData(); + const { environment, hasVercelIntegration, canReadApiKeys } = useTypedLoaderData(); const organization = useOrganization(); if (!environment) { @@ -126,70 +144,78 @@ export default function Page() { API keys
-
- -
- - -
- - - Set this as your TRIGGER_SECRET_KEY{" "} - env var in your backend. - -
- {environment.branchName && ( + {canReadApiKeys ? ( +
- +
+ + +
- Set this as your{" "} - TRIGGER_PREVIEW_BRANCH env var in - your backend. + Set this as your TRIGGER_SECRET_KEY{" "} + env var in your backend.
- )} - {environment.type === "DEVELOPMENT" && ( - - Every team member gets their own dev Secret key. Make sure you're using the one - above otherwise you will trigger runs on your team member's machine. - - )} + {environment.branchName && ( + + + + + Set this as your{" "} + TRIGGER_PREVIEW_BRANCH env var in + your backend. + + + )} + {environment.type === "DEVELOPMENT" && ( + + Every team member gets their own dev Secret key. Make sure you're using the one + above otherwise you will trigger runs on your team member's machine. + + )} - - - How to set these environment variables - -
-
- You need to set these environment variables in your backend. This allows the - SDK to authenticate with Trigger.dev. + + + How to set these environment variables + +
+
+ You need to set these environment variables in your backend. This allows the + SDK to authenticate with Trigger.dev. +
+
- -
- - - -
+
+
+
+
+ ) : ( + + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index 0bd53caac30..f05ec143817 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -1,6 +1,5 @@ import { ArrowPathIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import type { BulkActionType } from "@trigger.dev/database"; import { motion } from "framer-motion"; @@ -10,6 +9,7 @@ import { ExitIcon } from "~/assets/icons/ExitIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { BulkActionFilterSummary } from "~/components/BulkActionFilterSummary"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { PermissionButton } from "~/components/primitives/PermissionButton"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; import { Header2 } from "~/components/primitives/Headers"; @@ -23,11 +23,13 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { BulkActionPresenter } from "~/presenters/v3/BulkActionPresenter.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { cn } from "~/utils/cn"; import { formatNumber } from "~/utils/numberFormatter"; import { @@ -43,71 +45,102 @@ const BulkActionParamSchema = EnvironmentParamSchema.extend({ bulkActionParam: z.string(), }); -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); +export const loader = dashboardLoader( + { + params: BulkActionParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "read", resource: { type: "runs" } }, + }, + async ({ params, user, ability }) => { + const { organizationSlug, projectParam, envParam, bulkActionParam } = params; - const { organizationSlug, projectParam, envParam, bulkActionParam } = - BulkActionParamSchema.parse(params); + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } + try { + const presenter = new BulkActionPresenter(); + const [error, data] = await tryCatch( + presenter.call({ + environmentId: environment.id, + bulkActionId: bulkActionParam, + }) + ); - try { - const presenter = new BulkActionPresenter(); - const [error, data] = await tryCatch( - presenter.call({ - environmentId: environment.id, - bulkActionId: bulkActionParam, - }) - ); + if (error) { + throw new Error(error.message); + } - if (error) { - throw new Error(error.message); - } + const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS; - const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS; + // Display flag for the Abort button — the action enforces write:runs. + const { canAbort } = checkPermissions(ability, { + canAbort: { action: "write", resource: { type: "runs" } }, + }); - return typedjson({ bulkAction: data, autoReloadPollIntervalMs }); - } catch (error) { - console.error(error); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", - }); + return typedjson({ bulkAction: data, autoReloadPollIntervalMs, canAbort }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } } -}; +); -export const action = async ({ request, params }: ActionFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, bulkActionParam } = - BulkActionParamSchema.parse(params); +export const action = dashboardAction( + { + params: BulkActionParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "runs" } }, + }, + async ({ request, params, user }) => { + const { organizationSlug, projectParam, envParam, bulkActionParam } = params; - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - const service = new BulkActionService(); - const [error, result] = await tryCatch(service.abort(bulkActionParam, environment.id)); + const service = new BulkActionService(); + const [error, result] = await tryCatch(service.abort(bulkActionParam, environment.id)); - if (error) { - logger.error("Failed to abort bulk action", { - error, - }); + if (error) { + logger.error("Failed to abort bulk action", { + error, + }); - return redirectWithErrorMessage( + return redirectWithErrorMessage( + v3BulkActionPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: bulkActionParam } + ), + request, + `Failed to abort bulk action: ${error.message}` + ); + } + + return redirectWithSuccessMessage( v3BulkActionPath( { slug: organizationSlug }, { slug: projectParam }, @@ -115,24 +148,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { { friendlyId: bulkActionParam } ), request, - `Failed to abort bulk action: ${error.message}` + "Bulk action aborted" ); } - - return redirectWithSuccessMessage( - v3BulkActionPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam }, - { friendlyId: bulkActionParam } - ), - request, - "Bulk action aborted" - ); -}; +); export default function Page() { - const { bulkAction, autoReloadPollIntervalMs } = useTypedLoaderData(); + const { bulkAction, autoReloadPollIntervalMs, canAbort } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -162,9 +184,14 @@ export default function Page() { {bulkAction.status === "PENDING" ? (
- +
) : null}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 9dbac88c51a..e860c312317 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -66,11 +66,14 @@ import { import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { type DeploymentListItem, DeploymentListPresenter, } from "~/presenters/v3/DeploymentListPresenter.server"; import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { titleCase } from "~/utils"; import { cn } from "~/utils/cn"; import { @@ -141,7 +144,25 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS; - return typedjson({ ...result, selectedDeployment, autoReloadPollIntervalMs }); + // Display flag for the rollback/promote/cancel controls — the action + // routes enforce write:deployments independently. Permissive in OSS. + const orgId = await resolveOrgIdFromSlug(organizationSlug); + const deploymentAuth = orgId + ? await rbac.authenticateSession(request, { userId, organizationId: orgId }) + : null; + const canWriteDeployments = + deploymentAuth && deploymentAuth.ok + ? checkPermissions(deploymentAuth.ability, { + canWriteDeployments: { action: "write", resource: { type: "deployments" } }, + }).canWriteDeployments + : true; + + return typedjson({ + ...result, + selectedDeployment, + autoReloadPollIntervalMs, + canWriteDeployments, + }); } catch (error) { console.error(error); throw new Response(undefined, { @@ -164,6 +185,7 @@ export default function Page() { environmentGitHubBranch, autoReloadPollIntervalMs, hasVercelIntegration, + canWriteDeployments, } = useTypedLoaderData(); const hasDeployments = totalPages > 0; @@ -257,7 +279,12 @@ export default function Page() {
- {deployment.shortCode} + + {deployment.shortCode} + {deployment.label && ( {titleCase(deployment.label)} )} @@ -333,6 +360,7 @@ export default function Page() { path={path} isSelected={isSelected} currentDeployment={currentDeployment} + canWriteDeployments={canWriteDeployments} /> ); @@ -419,8 +447,14 @@ export default function Page() { export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) { return (
- - {name} + + + {name} +
); } @@ -430,11 +464,13 @@ function DeploymentActionsCell({ path, isSelected, currentDeployment, + canWriteDeployments, }: { deployment: DeploymentListItem; path: string; isSelected: boolean; currentDeployment?: DeploymentListItem; + canWriteDeployments: boolean; }) { const location = useLocation(); const project = useProject(); @@ -463,66 +499,105 @@ function DeploymentActionsCell({ isSelected={isSelected} popoverContent={ <> - {canBeRolledBack && ( - - - - - - - )} - {canBePromoted && ( - - - - - - - )} - {canBeCanceled && ( - - - - - - - )} + {canBeRolledBack && + (canWriteDeployments ? ( + + + + + + + ) : ( + + ))} + {canBePromoted && + (canWriteDeployments ? ( + + + + + + + ) : ( + + ))} + {canBeCanceled && + (canWriteDeployments ? ( + + + + + + + ) : ( + + ))} } /> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 4c318323b58..06847acd1de 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -7,15 +7,21 @@ import { useForm, } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { LockClosedIcon, LockOpenIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { + LockClosedIcon, + LockOpenIcon, + NoSymbolIcon, + PlusIcon, + XMarkIcon, +} from "@heroicons/react/20/solid"; import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react"; -import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; import dotenv from "dotenv"; import { type RefObject, useCallback, useRef, useState } from "react"; import { redirect } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentLabel, environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; @@ -41,7 +47,8 @@ import { useList } from "~/hooks/useList"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; -import { requireUserId } from "~/services/session.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { environmentVariablesRouteId, @@ -95,74 +102,102 @@ const schema = z.object({ }, Variable.array().nonempty("At least one variable is required")), }); -export const action = async ({ request, params }: ActionFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // Per-environment write:envvars is enforced in the handler — the target + // environments come from the submission, not the route params. + }, + async ({ request, params, user, ability }) => { + const userId = user.id; + const { organizationSlug, projectParam, envParam } = params; + + if (request.method.toUpperCase() !== "POST") { + throw new Response("Method Not Allowed", { status: 405 }); + } - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } + const formData = await request.formData(); + const submission = parse(formData, { schema }); - const formData = await request.formData(); - const submission = parse(formData, { schema }); + if (!submission.value) { + return json(submission); + } - if (!submission.value) { - return json(submission); - } + // Enforce env-tier write:envvars for every targeted environment, so a role + // that can't write a deployed tier can't create vars there via a direct + // POST (the disabled checkboxes are not the boundary). + const targetEnvironments = await prisma.runtimeEnvironment.findMany({ + where: { id: { in: submission.value.environmentIds } }, + select: { type: true }, + }); + const hasDeniedEnvironment = targetEnvironments.some( + (env) => !ability.can("write", { type: "envvars", envType: env.type }) + ); + if (hasDeniedEnvironment) { + submission.error.environmentIds = [ + "You don't have permission to manage environment variables in one of the selected environments.", + ]; + return json(submission); + } - const project = await prisma.project.findUnique({ - where: { - slug: params.projectParam, - organization: { - members: { - some: { - userId, + const project = await prisma.project.findUnique({ + where: { + slug: params.projectParam, + organization: { + members: { + some: { + userId, + }, }, }, }, - }, - select: { - id: true, - }, - }); - if (!project) { - submission.error.key = ["Project not found"]; - return json(submission); - } + select: { + id: true, + }, + }); + if (!project) { + submission.error.key = ["Project not found"]; + return json(submission); + } - const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.create(project.id, { - ...submission.value, - lastUpdatedBy: { - type: "user", - userId, - }, - }); + const repository = new EnvironmentVariablesRepository(prisma); + const result = await repository.create(project.id, { + ...submission.value, + lastUpdatedBy: { + type: "user", + userId, + }, + }); - if (!result.success) { - if (result.variableErrors) { - for (const { key, error } of result.variableErrors) { - const index = submission.value.variables.findIndex((v) => v.key === key); + if (!result.success) { + if (result.variableErrors) { + for (const { key, error } of result.variableErrors) { + const index = submission.value.variables.findIndex((v) => v.key === key); - if (index !== -1) { - submission.error[`variables[${index}].key`] = [error]; + if (index !== -1) { + submission.error[`variables[${index}].key`] = [error]; + } } + } else { + submission.error.variables = [result.error]; } - } else { - submission.error.variables = [result.error]; + + return json(submission); } - return json(submission); + return redirect( + v3EnvironmentVariablesPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ) + ); } - - return redirect( - v3EnvironmentVariablesPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ) - ); -}; +); export default function Page() { const [isOpen, setIsOpen] = useState(true); @@ -173,7 +208,9 @@ export default function Page() { parentData, "Environment variables page loader data must be defined when rendering the create dialog" ); - const { environments, hasStaging } = parentData; + const { environments, hasStaging, writableEnvironmentIds } = parentData; + // Creating a variable is a write, so gate the targets on write access. + const writableEnvironmentIdSet = new Set(writableEnvironmentIds); const lastSubmission = useActionData(); const navigation = useNavigation(); const navigate = useNavigate(); @@ -269,19 +306,45 @@ export default function Page() { )) )}
- {nonBranchEnvironments.map((environment) => ( - - handleEnvironmentChange(environment.id, isChecked, environment.type) - } - label={} - variant="button" - /> - ))} + {nonBranchEnvironments.map((environment) => + writableEnvironmentIdSet.has(environment.id) ? ( + + handleEnvironmentChange(environment.id, isChecked, environment.type) + } + label={} + variant="button" + /> + ) : ( + + + +
+ + } + variant="button" + /> +
+
+ + + With your current role, you can't manage{" "} + {environmentFullTitle(environment)} environment variables. + +
+
+ ) + )} {!hasStaging && ( <> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 389551fc75f..d59d1c40b73 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -4,6 +4,7 @@ import { BookOpenIcon, InformationCircleIcon, LockClosedIcon, + NoSymbolIcon, PencilSquareIcon, PlusIcon, TrashIcon, @@ -17,16 +18,9 @@ import { useNavigation, useRevalidator, } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type RefObject, -} from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -65,16 +59,16 @@ import { useSearchParams } from "~/hooks/useSearchParam"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { type EnvironmentVariableWithSetValues, EnvironmentVariablesPresenter, } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; import { type EnvironmentVariablesEnvironment } from "~/presenters/v3/environmentVariablesEnvironments.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, - ProjectParamSchema, docsPath, v3EnvironmentVariablesPath, v3NewEnvironmentVariablesPath, @@ -107,42 +101,90 @@ type PageVercelIntegration = NonNullable< Awaited>["vercelIntegration"] >; +// A value the current role can't read for its environment tier is masked +// server-side: the value is withheld and the cell renders "Permission denied". +export type MaskedEnvironmentVariable = EnvironmentVariableWithSetValues & { + permissionDenied?: boolean; +}; + export type EnvironmentVariablesPageLoaderData = { - environmentVariables: EnvironmentVariableWithSetValues[]; + environmentVariables: MaskedEnvironmentVariable[]; environments: EnvironmentVariablesEnvironment[]; hasStaging: boolean; vercelIntegration: PageVercelIntegration | null; + // Environment ids whose env vars the current role can read. + accessibleEnvironmentIds: string[]; + // Environment ids whose env vars the current role can write (create/edit/delete). + writableEnvironmentIds: string[]; }; export const environmentVariablesRouteId = "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam } = ProjectParamSchema.parse(params); +export const loader = dashboardLoader( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // No hard authorization: the page lists every environment. Values in + // environments the role can't read are masked per-tier below. + }, + async ({ params, user, ability }) => { + const { projectParam } = params; + + try { + const presenter = new EnvironmentVariablesPresenter(); + const { environmentVariables, environments, hasStaging, vercelIntegration } = + await presenter.call({ + userId: user.id, + projectSlug: projectParam, + }); + + const accessibleEnvironmentIds = environments + .filter((env) => ability.can("read", { type: "envvars", envType: env.type })) + .map((env) => env.id); + const accessible = new Set(accessibleEnvironmentIds); + + // Write access is a separate grant from read: gate write controls (edit, + // delete, create) on this set, not on read-accessibility. + const writableEnvironmentIds = environments + .filter((env) => ability.can("write", { type: "envvars", envType: env.type })) + .map((env) => env.id); + + // Withhold values (and the "who/when" metadata) for environments the + // role can't read — never serialize them to the client. + const masked: MaskedEnvironmentVariable[] = environmentVariables.map((variable) => + accessible.has(variable.environment.id) + ? variable + : { + ...variable, + value: "", + isSecret: false, + permissionDenied: true, + lastUpdatedBy: null, + updatedByUser: null, + } + ); - try { - const presenter = new EnvironmentVariablesPresenter(); - const { environmentVariables, environments, hasStaging, vercelIntegration } = - await presenter.call({ - userId, - projectSlug: projectParam, + return typedjson({ + environmentVariables: masked, + environments, + hasStaging, + vercelIntegration, + accessibleEnvironmentIds, + writableEnvironmentIds, }); - - return typedjson({ - environmentVariables, - environments, - hasStaging, - vercelIntegration, - }); - } catch (error) { - console.error(error); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", - }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } } -}; +); const schema = z.discriminatedUnion("action", [ z.object({ action: z.literal("edit"), ...EditEnvironmentVariableValue.shape }), @@ -161,131 +203,160 @@ const schema = z.discriminatedUnion("action", [ }), ]); -export const action = async ({ request, params }: ActionFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // Per-environment write:envvars is enforced in the handler — the target + // environment tier comes from the submission, not the route params. + }, + async ({ request, params, user, ability }) => { + const userId = user.id; + const { organizationSlug, projectParam, envParam } = params; + + if (request.method.toUpperCase() !== "POST") { + throw new Response("Method Not Allowed", { status: 405 }); + } - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } + const formData = await request.formData(); + const submission = parse(formData, { schema }); - const formData = await request.formData(); - const submission = parse(formData, { schema }); + if (!submission.value) { + return json(submission); + } - if (!submission.value) { - return json(submission); - } + // Enforce env-tier write:envvars on the targeted environment, so a role + // that can't write a deployed tier can't mutate it via a direct POST. + const targetEnvType = + submission.value.action === "update-vercel-sync" + ? submission.value.environmentType + : ( + await prisma.runtimeEnvironment.findFirst({ + where: { id: submission.value.environmentId }, + select: { type: true }, + }) + )?.type; + if (targetEnvType && !ability.can("write", { type: "envvars", envType: targetEnvType })) { + submission.error.key = [ + "You don't have permission to manage environment variables in this environment.", + ]; + return json(submission); + } - const project = await prisma.project.findUnique({ - where: { - slug: params.projectParam, - organization: { - members: { - some: { - userId, + const project = await prisma.project.findUnique({ + where: { + slug: params.projectParam, + organization: { + members: { + some: { + userId, + }, }, }, }, - }, - select: { - id: true, - }, - }); - if (!project) { - submission.error.key = ["Project not found"]; - return json(submission); - } + select: { + id: true, + }, + }); + if (!project) { + submission.error.key = ["Project not found"]; + return json(submission); + } - switch (submission.value.action) { - case "edit": { - const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.editValue(project.id, { - ...submission.value, - lastUpdatedBy: { - type: "user", - userId, - }, - }); + switch (submission.value.action) { + case "edit": { + const repository = new EnvironmentVariablesRepository(prisma); + const result = await repository.editValue(project.id, { + ...submission.value, + lastUpdatedBy: { + type: "user", + userId, + }, + }); - if (!result.success) { - submission.error.key = [result.error]; - return json(submission); + if (!result.success) { + submission.error.key = [result.error]; + return json(submission); + } + + return json({ ...submission, success: true }); } + case "delete": { + const repository = new EnvironmentVariablesRepository(prisma); + const result = await repository.deleteValue(project.id, submission.value); - return json({ ...submission, success: true }); - } - case "delete": { - const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.deleteValue(project.id, submission.value); + if (!result.success) { + submission.error.key = [result.error]; + return json(submission); + } - if (!result.success) { - submission.error.key = [result.error]; - return json(submission); + // Clean up syncEnvVarsMapping if Vercel integration exists (best-effort) + const { environmentId, key } = submission.value; + const vercelService = new VercelIntegrationService(); + await fromPromise( + (async () => { + const integration = await vercelService.getVercelProjectIntegration(project.id); + if (integration) { + const runtimeEnv = await prisma.runtimeEnvironment.findUnique({ + where: { id: environmentId }, + select: { type: true }, + }); + if (runtimeEnv) { + await vercelService.removeSyncEnvVarForEnvironment( + project.id, + key, + runtimeEnv.type as TriggerEnvironmentType + ); + } + } + })(), + (error) => error + ).mapErr((error) => { + logger.error("Failed to remove Vercel sync mapping", { error }); + return error; + }); + + return redirectWithSuccessMessage( + v3EnvironmentVariablesPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ), + request, + `Deleted ${submission.value.key} environment variable` + ); } + case "update-vercel-sync": { + const vercelService = new VercelIntegrationService(); + const integration = await vercelService.getVercelProjectIntegration(project.id); - // Clean up syncEnvVarsMapping if Vercel integration exists (best-effort) - const { environmentId, key } = submission.value; - const vercelService = new VercelIntegrationService(); - await fromPromise( - (async () => { - const integration = await vercelService.getVercelProjectIntegration(project.id); - if (integration) { - const runtimeEnv = await prisma.runtimeEnvironment.findUnique({ - where: { id: environmentId }, - select: { type: true }, - }); - if (runtimeEnv) { - await vercelService.removeSyncEnvVarForEnvironment( - project.id, - key, - runtimeEnv.type as TriggerEnvironmentType - ); - } - } - })(), - (error) => error - ).mapErr((error) => { - logger.error("Failed to remove Vercel sync mapping", { error }); - return error; - }); + if (!integration) { + submission.error.key = ["Vercel integration not found"]; + return json(submission); + } - return redirectWithSuccessMessage( - v3EnvironmentVariablesPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ), - request, - `Deleted ${submission.value.key} environment variable` - ); - } - case "update-vercel-sync": { - const vercelService = new VercelIntegrationService(); - const integration = await vercelService.getVercelProjectIntegration(project.id); + // Update the sync mapping for the specific env var and environment + await vercelService.updateSyncEnvVarForEnvironment( + project.id, + submission.value.key, + submission.value.environmentType, + submission.value.syncEnabled + ); - if (!integration) { - submission.error.key = ["Vercel integration not found"]; - return json(submission); + return json({ success: true }); } - - // Update the sync mapping for the specific env var and environment - await vercelService.updateSyncEnvVarForEnvironment( - project.id, - submission.value.key, - submission.value.environmentType, - submission.value.syncEnabled - ); - - return json({ success: true }); } } -}; +); const SSR_ROW_WINDOW = 50; const ROW_ESTIMATE_HEIGHT = 44; const VIRTUAL_OVERSCAN = 10; -type GroupedEnvironmentVariable = EnvironmentVariableWithSetValues & { +type GroupedEnvironmentVariable = MaskedEnvironmentVariable & { isFirstTime: boolean; isLastTime: boolean; occurences: number; @@ -538,16 +609,22 @@ function EnvironmentVariableTableRow({ const borderedCellClassName = getBorderedCellClassName(variable); return ( - + - {variable.isFirstTime ? ( - - ) : null} + {variable.isFirstTime ? : null} - {variable.isSecret ? ( + {variable.permissionDenied ? ( + + + Permission denied +
+ } + content="With your current role, you can't view this environment's variables." + /> + ) : variable.isSecret ? ( @@ -620,10 +697,14 @@ function EnvironmentVariableTableRow({ isSticky className="[&:has(.group-hover/table-row:block)]:w-auto w-0" hiddenButtons={ - <> - - - + // No edit/delete for environments the role can't manage — the value + // is withheld, and the action enforces write:envvars independently. + variable.permissionDenied ? undefined : ( + <> + + + + ) } /> @@ -652,8 +733,7 @@ function EnvironmentVariablesVirtualTableBody({ const virtualItems = rowVirtualizer.getVirtualItems(); const topSpacerHeight = virtualItems[0]?.start ?? 0; - const bottomSpacerHeight = - rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0); + const bottomSpacerHeight = rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0); return ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 91a3f083e48..a84e445539d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -36,6 +36,7 @@ import { PageBody } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter"; import { LinkButton } from "~/components/primitives/Buttons"; +import { PermissionLink } from "~/components/primitives/PermissionLink"; import { Callout } from "~/components/primitives/Callout"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime"; @@ -74,6 +75,8 @@ import { import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { requireUser, requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, @@ -282,6 +285,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ) .catch(() => ({ data: [] as ErrorGroupActivity, versions: [] as string[] })); + // Display flags for the row-menu and bulk-replay controls — the cancel/ + // replay action routes enforce write:runs independently. Permissive in OSS. + const runAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const runPermissions = runAuth.ok + ? checkPermissions(runAuth.ability, { + canCancelRuns: { action: "write", resource: { type: "runs" } }, + canReplayRuns: { action: "write", resource: { type: "runs" } }, + }) + : { canCancelRuns: true, canReplayRuns: true }; + return typeddefer({ data: detailPromise, activity: activityPromise, @@ -289,12 +305,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { projectParam, envParam, fingerprint, + ...runPermissions, }); }; export default function Page() { - const { data, activity, organizationSlug, projectParam, envParam, fingerprint } = - useTypedLoaderData(); + const { + data, + activity, + organizationSlug, + projectParam, + envParam, + fingerprint, + canCancelRuns, + canReplayRuns, + } = useTypedLoaderData(); const location = useOptimisticLocation(); const searchParams = new URLSearchParams(location.search); @@ -387,6 +412,8 @@ export default function Page() { projectParam={projectParam} envParam={envParam} fingerprint={fingerprint} + canCancelRuns={canCancelRuns} + canReplayRuns={canReplayRuns} /> ); }} @@ -405,6 +432,8 @@ function ErrorGroupDetail({ projectParam, envParam, fingerprint, + canCancelRuns, + canReplayRuns, }: { errorGroup: ErrorGroupSummary | undefined; runList: NextRunList | undefined; @@ -413,6 +442,8 @@ function ErrorGroupDetail({ projectParam: string; envParam: string; fingerprint: string; + canCancelRuns: boolean; + canReplayRuns: boolean; }) { const { value, values } = useSearchParams(); const organization = useOrganization(); @@ -482,7 +513,9 @@ function ErrorGroupDetail({ > View all runs - Bulk replay… - +
)} @@ -515,6 +548,8 @@ function ErrorGroupDetail({ isLoading={false} variant="dimmed" additionalTableState={{ errorId: ErrorId.toFriendlyId(fingerprint) }} + canCancelRuns={canCancelRuns} + canReplayRuns={canReplayRuns} /> ) : (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx index b81ca842c47..896590c884e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx @@ -2,12 +2,7 @@ import * as Ariakit from "@ariakit/react"; import { ArrowPathIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { type MetaFunction, useFetcher } from "@remix-run/react"; -import { - type ActionFunctionArgs, - json, - type LoaderFunctionArgs, - redirect, -} from "@remix-run/server-runtime"; +import { json, type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { AnimatePresence, motion } from "framer-motion"; import { ClipboardCheckIcon, ClipboardIcon, GitBranchPlusIcon } from "lucide-react"; @@ -22,6 +17,7 @@ import { ProvidersFilter } from "~/components/metrics/ProvidersFilter"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { PermissionButton } from "~/components/primitives/PermissionButton"; import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; import { Header3 } from "~/components/primitives/Headers"; @@ -66,6 +62,7 @@ import { useInterval } from "~/hooks/useInterval"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type GenerationRow, PromptPresenter } from "~/presenters/v3/PromptPresenter.server"; @@ -73,6 +70,9 @@ import { SpanView } from "~/routes/resources.orgs.$organizationSlug.projects.$pr import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { PromptService } from "~/v3/services/promptService.server"; import { z } from "zod"; @@ -122,85 +122,110 @@ const ActionSchema = z.discriminatedUnion("intent", [ }), ]); -export async function action({ request, params }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, promptSlug } = ParamSchema.parse(params); +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + }, + async ({ request, params, user, ability, context }) => { + const { organizationSlug, projectParam, envParam, promptSlug } = params; + + // This action checks permissions per intent inline (below) rather than via + // a top-level authorization block, so the builder's fail-closed scope guard + // doesn't run. Enforce it here: without a resolved org the inline + // ability.can checks would evaluate an unscoped ability. + if (!context.organizationId) { + return json({ error: "Unauthorized" }, { status: 403 }); + } - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) return json({ error: "Project not found" }, { status: 404 }); + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) return json({ error: "Project not found" }, { status: 404 }); - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) return json({ error: "Environment not found" }, { status: 404 }); + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) return json({ error: "Environment not found" }, { status: 404 }); - const formData = Object.fromEntries(await request.formData()); - const parsed = ActionSchema.safeParse(formData); - if (!parsed.success) return json({ error: "Invalid action" }, { status: 400 }); + const formData = Object.fromEntries(await request.formData()); + const parsed = ActionSchema.safeParse(formData); + if (!parsed.success) return json({ error: "Invalid action" }, { status: 400 }); - const prompt = await prisma.prompt.findUnique({ - where: { - projectId_runtimeEnvironmentId_slug: { - projectId: project.id, - runtimeEnvironmentId: environment.id, - slug: promptSlug, + const prompt = await prisma.prompt.findUnique({ + where: { + projectId_runtimeEnvironmentId_slug: { + projectId: project.id, + runtimeEnvironmentId: environment.id, + slug: promptSlug, + }, }, - }, - }); + }); - if (!prompt) return json({ error: "Prompt not found" }, { status: 404 }); + if (!prompt) return json({ error: "Prompt not found" }, { status: 404 }); - const data = parsed.data; - const service = new PromptService(); + const data = parsed.data; - if (data.intent === "promote") { - await service.promoteVersion(prompt.id, data.versionId); - return json({ ok: true }); - } + // Promoting a version to production is `update:prompts`; creating or + // editing override versions is `write:prompts`. Check the right one per + // intent — a single authorization block can't express both. + const requiredAction = data.intent === "promote" ? "update" : "write"; + if (!ability.can(requiredAction, { type: "prompts" })) { + return json({ error: "Unauthorized" }, { status: 403 }); + } - const url = new URL(request.url); + const service = new PromptService(); - if (data.intent === "saveVersion") { - const result = await service.createOverride(prompt.id, { - textContent: data.textContent ?? "", - model: data.model, - commitMessage: data.commitMessage, - source: "dashboard", - createdBy: userId, - }); - url.searchParams.set("version", String(result.version)); - return redirect(url.pathname + url.search); - } + if (data.intent === "promote") { + await service.promoteVersion(prompt.id, data.versionId); + return json({ ok: true }); + } - if (data.intent === "updateOverride") { - await service.updateOverride(prompt.id, { - textContent: data.textContent, - model: data.model, - commitMessage: data.commitMessage, - }); - return json({ ok: true }); - } + const url = new URL(request.url); - if (data.intent === "removeOverride") { - await service.removeOverride(prompt.id); - // Navigate back to current version - const currentVersion = await prisma.promptVersion.findFirst({ - where: { promptId: prompt.id, labels: { has: "current" } }, - select: { version: true }, - }); - if (currentVersion) { - url.searchParams.set("version", String(currentVersion.version)); - } else { - url.searchParams.delete("version"); + if (data.intent === "saveVersion") { + const result = await service.createOverride(prompt.id, { + textContent: data.textContent ?? "", + model: data.model, + commitMessage: data.commitMessage, + source: "dashboard", + createdBy: user.id, + }); + url.searchParams.set("version", String(result.version)); + return redirect(url.pathname + url.search); } - return redirect(url.pathname + url.search); - } - if (data.intent === "reactivateOverride") { - await service.reactivateOverride(prompt.id, data.versionId); - return json({ ok: true }); - } + if (data.intent === "updateOverride") { + await service.updateOverride(prompt.id, { + textContent: data.textContent, + model: data.model, + commitMessage: data.commitMessage, + }); + return json({ ok: true }); + } - return json({ error: "Unknown intent" }, { status: 400 }); -} + if (data.intent === "removeOverride") { + await service.removeOverride(prompt.id); + // Navigate back to current version + const currentVersion = await prisma.promptVersion.findFirst({ + where: { promptId: prompt.id, labels: { has: "current" } }, + select: { version: true }, + }); + if (currentVersion) { + url.searchParams.set("version", String(currentVersion.version)); + } else { + url.searchParams.delete("version"); + } + return redirect(url.pathname + url.search); + } + + if (data.intent === "reactivateOverride") { + await service.reactivateOverride(prompt.id, data.versionId); + return json({ ok: true }); + } + + return json({ error: "Unknown intent" }, { status: 400 }); + } +); // ─── Loader ────────────────────────────────────────────── @@ -242,7 +267,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const startTime = fromTime ? new Date(fromTime) : new Date(Date.now() - periodMs); const endTime = toTime ? new Date(toTime) : new Date(); - const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard"); + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); const presenter = new PromptPresenter(clickhouse); let generations: Awaited>["generations"] = []; let generationsPagination: { next?: string } = {}; @@ -301,6 +329,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const possibleOperations = opsErr ? [] : opsRows.map((r) => r.val); const possibleProviders = provsErr ? [] : provsRows.map((r) => r.val); + // Display flags for the promote / override controls — the action enforces + // update:prompts and write:prompts independently. Permissive in OSS. + const promptAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const promptPermissions = promptAuth.ok + ? checkPermissions(promptAuth.ability, { + canWritePrompts: { action: "write", resource: { type: "prompts" } }, + canPromote: { action: "update", resource: { type: "prompts" } }, + }) + : { canWritePrompts: true, canPromote: true }; + return typedjson({ resizable: { outer: resizableOuter, @@ -353,6 +394,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { possibleModels, possibleOperations, possibleProviders, + ...promptPermissions, }); }; @@ -437,6 +479,8 @@ export default function PromptDetailPage() { possibleModels, possibleOperations, possibleProviders, + canWritePrompts, + canPromote, } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -518,18 +562,22 @@ export default function PromptDetailPage() {
)} {selectedVersion && !isCurrent && selectedVersion.source === "code" && ( - + )} {selectedVersion && selectedVersion.source !== "code" && !selectedVersion.labels.includes("override") && ( - + )} {!overrideVersion && ( - + )} @@ -565,21 +618,25 @@ export default function PromptDetailPage() { instead of the deployed prompt.
- - +
)} @@ -1502,7 +1559,10 @@ function GenerationsTab({ {gen.operation_id || gen.task_identifier}
v{gen.prompt_version} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 45d2a06b15a..de23b935cd6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -104,6 +104,7 @@ import { getImpersonationId } from "~/services/impersonation.server"; import { logger } from "~/services/logger.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; import { cn } from "~/utils/cn"; import { lerp } from "~/utils/lerp"; import { @@ -189,7 +190,10 @@ async function getRunsListFromTableState({ return null; } - const clickhouse = await clickhouseFactory.getClickhouseForOrganization(project.organizationId, "standard"); + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "standard" + ); const runsListPresenter = new NextRunListPresenter($replica, clickhouse); const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, { userId, @@ -253,6 +257,15 @@ async function getRunsListFromTableState({ } } +// Display-only write:runs flags for the Replay/Cancel controls. The cancel +// and replay action routes enforce write:runs independently; this mirrors the +// result so the buttons disable for roles that lack it. Permissive in OSS. +async function runWritePermissions(request: Request, userId: string, organizationId: string) { + const auth = await rbac.authenticateSession(request, { userId, organizationId }); + const canWriteRun = auth.ok ? auth.ability.can("write", { type: "runs" }) : true; + return { canReplayRun: canWriteRun, canCancelRun: canWriteRun }; +} + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const impersonationId = await getImpersonationId(request); @@ -318,11 +331,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // Skip on `_data` requests (Remix data fetches): they're // client-driven follow-ups and the client URL is what matters, // not the loader's view of it. - if ( - !url.searchParams.has("span") && - !url.searchParams.has("_data") && - buffered.run.spanId - ) { + if (!url.searchParams.has("span") && !url.searchParams.has("_data") && buffered.run.spanId) { url.searchParams.set("span", buffered.run.spanId); throw redirect(url.pathname + "?" + url.searchParams.toString()); } @@ -336,6 +345,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS, resizable: { parent, tree }, runsList: null, + ...(await runWritePermissions(request, userId, buffered.run.environment.organizationId)), }); } @@ -347,11 +357,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // block in the buffered fallback above — the sibling redirect routes // do this, but direct navigation to the canonical project-scoped URL // never hits them, leaving the right detail panel collapsed. - if ( - !url.searchParams.has("span") && - !url.searchParams.has("_data") && - result.run.spanId - ) { + if (!url.searchParams.has("span") && !url.searchParams.has("_data") && result.run.spanId) { url.searchParams.set("span", result.run.spanId); throw redirect(url.pathname + "?" + url.searchParams.toString()); } @@ -378,6 +384,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { tree, }, runsList, + ...(await runWritePermissions(request, userId, result.run.environment.organizationId)), }); }; @@ -417,8 +424,15 @@ async function tryMollifiedRunFallback(args: { type LoaderData = SerializeFrom; export default function Page() { - const { run, trace, maximumLiveReloadingSetting, runsList, resizable } = - useLoaderData(); + const { + run, + trace, + maximumLiveReloadingSetting, + runsList, + resizable, + canReplayRun, + canCancelRun, + } = useLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -500,6 +514,8 @@ export default function Page() { LeadingIcon={ArrowUturnLeftIcon} shortcut={{ key: "R" }} className="pr-2" + disabled={!canReplayRun} + tooltip={canReplayRun ? undefined : "You don't have permission to replay runs"} > Replay run @@ -518,6 +534,7 @@ export default function Page() { {run.isFinished ? null : ( - diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 78c60904a6b..f00a4548167 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -55,6 +55,8 @@ import { uiPreferencesStorage, } from "~/services/preferences/uiPreferences.server"; import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { cn } from "~/utils/cn"; import { docsPath, @@ -67,7 +69,11 @@ import { throwNotFound } from "~/utils/httpErrors"; import { ListPagination } from "../../components/ListPagination"; import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction"; import { Callout } from "~/components/primitives/Callout"; -import { isRunsListLoading, RUNS_BULK_INSPECTOR_OPEN_VALUE, shouldRevalidateRunsList } from "./shouldRevalidateRunsList"; +import { + isRunsListLoading, + RUNS_BULK_INSPECTOR_OPEN_VALUE, + shouldRevalidateRunsList, +} from "./shouldRevalidateRunsList"; import { useRunsLiveReload } from "./useRunsLiveReload"; export { shouldRevalidateRunsList as shouldRevalidate }; @@ -120,18 +126,33 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } : undefined; + // Display flags for the row-menu and bulk-action controls — the cancel/ + // replay action routes enforce write:runs independently. Permissive in OSS. + const runAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const runPermissions = runAuth.ok + ? checkPermissions(runAuth.ability, { + canCancelRuns: { action: "write", resource: { type: "runs" } }, + canReplayRuns: { action: "write", resource: { type: "runs" } }, + }) + : { canCancelRuns: true, canReplayRuns: true }; + return typeddefer( { data: list, rootOnlyDefault: filters.rootOnly, filters, + ...runPermissions, }, headers ? { headers } : undefined ); }; export default function Page() { - const { data, rootOnlyDefault, filters } = useTypedLoaderData(); + const { data, rootOnlyDefault, filters, canCancelRuns, canReplayRuns } = + useTypedLoaderData(); const { isConnected } = useDevPresence(); const project = useProject(); const environment = useEnvironment(); @@ -190,6 +211,8 @@ export default function Page() { selectedItems={selectedItems} rootOnlyDefault={rootOnlyDefault} filters={filters} + canCancelRuns={canCancelRuns} + canReplayRuns={canReplayRuns} /> ); }} @@ -207,11 +230,15 @@ function RunsList({ selectedItems, rootOnlyDefault, filters, + canCancelRuns, + canReplayRuns, }: { list: Awaited["data"]>; selectedItems: Set; rootOnlyDefault: boolean; filters: TaskRunListSearchFilters; + canCancelRuns: boolean; + canReplayRuns: boolean; }) { const revalidator = useRevalidator(); const location = useLocation(); @@ -245,9 +272,10 @@ function RunsList({ revalidator.revalidate(); }; - // Shortcut keys for bulk actions + // Shortcut keys for bulk actions — disabled when the role can't perform them. useShortcutKeys({ shortcut: { key: "r" }, + disabled: !canReplayRuns, action: (e) => { replace({ bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE, @@ -258,6 +286,7 @@ function RunsList({ }); useShortcutKeys({ shortcut: { key: "c" }, + disabled: !canCancelRuns, action: (e) => { replace({ bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE, @@ -272,8 +301,7 @@ function RunsList({ !isShowingBulkActionInspector ); // Keep content mounted until onCollapseChange reports the panel is fully collapsed. - const showBulkInspectorContent = - isShowingBulkActionInspector || !isBulkInspectorPanelCollapsed; + const showBulkInspectorContent = isShowingBulkActionInspector || !isBulkInspectorPanelCollapsed; return ( @@ -326,7 +354,7 @@ function RunsList({ {/* Stay mounted while the inspector is open to avoid toolbar layout shift. */} @@ -285,7 +327,7 @@ export default function VercelIntegrationPage() { Remove Vercel Integration - This will permanently remove the Vercel integration and disconnect all projects. + This will permanently remove the Vercel integration and disconnect all projects. This action cannot be undone. Connected Projects ({connectedProjects.length}) - + {connectedProjects.length === 0 ? (
@@ -348,9 +390,7 @@ export default function VercelIntegrationPage() { {projectIntegration.externalEntityId} - - {formatDate(new Date(projectIntegration.createdAt))} - + {formatDate(new Date(projectIntegration.createdAt))} ); -} \ No newline at end of file +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx index 6b3d037fbfe..5d215fb5475 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx @@ -3,6 +3,7 @@ import { type MetaFunction } from "@remix-run/react"; import { useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; @@ -21,8 +22,8 @@ import { TableRow, } from "~/components/primitives/Table"; import { cn } from "~/utils/cn"; -import { $replica } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { rbac } from "~/services/rbac.server"; import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { useShowSelfServe } from "~/hooks/useShowSelfServe"; @@ -40,14 +41,6 @@ const Params = z.object({ organizationSlug: z.string(), }); -async function resolveOrgIdFromSlug(slug: string): Promise { - const org = await $replica.organization.findFirst({ - where: { slug }, - select: { id: true }, - }); - return org?.id ?? null; -} - export const loader = dashboardLoader( { params: Params, @@ -57,13 +50,13 @@ export const loader = dashboardLoader( }, authorization: { action: "read", resource: { type: "members" } }, }, - async ({ context }) => { + async ({ context, user }) => { const orgId = context.organizationId; if (!orgId) { throw new Response("Not Found", { status: 404 }); } - const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin] = + const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRole] = await Promise.all([ rbac.allRoles(orgId), rbac.getAssignableRoleIds(orgId), @@ -71,6 +64,7 @@ export const loader = dashboardLoader( rbac.systemRoles(orgId), // OSS self-host has no RBAC plugin. rbac.isUsingPlugin(), + rbac.getUserRole({ userId: user.id, organizationId: orgId }), ]); return typedjson({ @@ -79,6 +73,7 @@ export const loader = dashboardLoader( allPermissions, systemRoles, isUsingPlugin, + currentRoleName: currentRole?.name ?? null, }); } ); @@ -92,7 +87,7 @@ type RolePermission = LoaderRole["permissions"][number]; const FALLBACK_GROUP = "Other"; export default function Page() { - const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin } = + const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRoleName } = useTypedLoaderData(); const organization = useOrganization(); const showSelfServe = useShowSelfServe(); @@ -122,19 +117,24 @@ export default function Page() { {isUsingPlugin && showSelfServe ? : null} -
+
Roles control what each team member can do in {organization.title}. Compare what each role grants below; assign a role to a team member from the{" "} Team page. + {currentRoleName ? ( + + Your role is {currentRoleName}. + + ) : null}
-
+
{columns.length === 0 ? ( ) : ( - +
Permission @@ -287,6 +287,18 @@ function RoleCell({ const conditionalDeny = denied.find((p) => p.conditions); if (conditionalDeny?.conditions) { + const allowedEnvTypes = allowedEnvTypesFromDeny(conditionalDeny.conditions); + if (allowedEnvTypes) { + // Conditional grant: show the environments the permission is allowed in. + return ( +
+ {allowedEnvTypes.map((type) => ( + + ))} +
+ ); + } + // Conditions we can't map to environments fall back to a text label. return ( {conditionLabel(conditionalDeny.conditions)} ); @@ -298,6 +310,28 @@ function RoleCell({ ); } +const ENV_TYPES = ["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"] as const; +type EnvType = (typeof ENV_TYPES)[number]; + +// A conditional `cannot` rule denies the permission where the resource matches +// its condition, so the permission stays allowed everywhere else. Translate the +// envType condition into the set of environments where it's still allowed, or +// null when we can't interpret it (caller falls back to a text label). +function allowedEnvTypesFromDeny(conditions: Record): EnvType[] | null { + const envType = conditions.envType; + // Equality, e.g. { envType: "PRODUCTION" } → denied in prod, allowed elsewhere. + if (typeof envType === "string") { + return ENV_TYPES.includes(envType as EnvType) ? ENV_TYPES.filter((t) => t !== envType) : null; + } + // Negation, e.g. { envType: { $ne: "DEVELOPMENT" } } → denied everywhere except + // DEVELOPMENT, so allowed only in DEVELOPMENT. + if (envType && typeof envType === "object" && "$ne" in envType) { + const ne = (envType as { $ne: unknown }).$ne; + return typeof ne === "string" && ENV_TYPES.includes(ne as EnvType) ? [ne as EnvType] : null; + } + return null; +} + // Only `envType` is supported today. function conditionLabel(conditions: Record): string { if (typeof conditions.envType === "string") { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index f9e7c0b0ee1..044444c17ee 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -11,7 +11,7 @@ import { } from "@remix-run/react"; import { json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; -import { useEffect, useRef, useState } from "react"; +import { cloneElement, useEffect, useRef, useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -30,6 +30,7 @@ import { AlertTrigger, } from "~/components/primitives/Alert"; import { Button, ButtonContent, LinkButton } from "~/components/primitives/Buttons"; +import { PermissionButton } from "~/components/primitives/PermissionButton"; import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -51,6 +52,7 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; import { removeTeamMember } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server"; import { rbac } from "~/services/rbac.server"; @@ -80,19 +82,6 @@ const Params = z.object({ organizationSlug: z.string(), }); -// Resolve slug → orgId in the dashboardLoader's context callback so the -// rbac.authenticateSession call gets a real organizationId. The result -// is cached for the duration of the request and reused by the handler -// below (we re-find by slug there to get a typed value — the context -// only sees the loosely typed return type). -async function resolveOrgIdFromSlug(slug: string): Promise { - const org = await $replica.organization.findFirst({ - where: { slug }, - select: { id: true }, - }); - return org?.id ?? null; -} - export const loader = dashboardLoader( { params: Params, @@ -119,10 +108,12 @@ export const loader = dashboardLoader( } // Pre-compute manage authority server-side so the UI gating matches - // the action gating (the action enforces it independently). + // the action gating (the action enforces it independently). Seat + // purchases are a billing operation, so they gate on manage:billing. const canManageMembers = ability.can("manage", { type: "members" }); + const canManageBilling = ability.can("manage", { type: "billing" }); - return typedjson({ ...result, canManageMembers }); + return typedjson({ ...result, canManageMembers, canManageBilling }); } ); @@ -222,10 +213,9 @@ export const action = dashboardAction( ); } if (purchaseBlockReason === "managed_billing") { - return json( - { ok: false, error: "Contact us to request more seats." } as const, - { status: 403 } - ); + return json({ ok: false, error: "Contact us to request more seats." } as const, { + status: 403, + }); } const submission = parse(formData, { schema: PurchaseSchema }); @@ -263,17 +253,24 @@ export const action = dashboardAction( return json(submission); } - // Default intent: remove a member or leave the org. Self-leave (the - // actor removing their own membership) is always allowed. Removing - // another member requires `manage:members` — pre-RBAC the - // `removeTeamMember` model fn only verified the actor was a member - // of the target org, so any org member could remove any other - // member by id; this gate fixes that latent permissions hole. + // Default intent: remove a member or leave the org. Scope the target to + // the actor's organization: an orgMember id is a globally unique key, so an + // unscoped lookup (plus an unscoped delete in the model) would let a + // manager in one org remove members of another by submitting a foreign id. + // Self-leave is always allowed; removing someone else requires + // manage:members. + const orgId = context.organizationId; + if (!orgId) { + return json({ ok: false, error: "Organization not found" } as const, { status: 404 }); + } const targetMember = await $replica.orgMember.findFirst({ - where: { id: submission.value.memberId }, + where: { id: submission.value.memberId, organizationId: orgId }, select: { userId: true }, }); - const isSelfLeave = targetMember?.userId === userId; + if (!targetMember) { + return json({ ok: false, error: "Member not found" } as const, { status: 404 }); + } + const isSelfLeave = targetMember.userId === userId; if (!isSelfLeave && !ability.can("manage", { type: "members" })) { return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); } @@ -318,6 +315,7 @@ export default function Page() { assignableRoleIds, memberRoles, canManageMembers, + canManageBilling, } = useTypedLoaderData(); // Build a userId → roleId map so the dropdown's defaultValue matches // each member's current assignment without re-querying. @@ -423,8 +421,8 @@ export default function Page() {
- - + +
))} @@ -532,6 +530,7 @@ export default function Page() { usedSeats={limits.used} maxQuota={maxSeatQuota} planSeatLimit={planSeatLimit} + canManageBilling={canManageBilling} /> ) : canUpgrade ? ( showSelfServe ? ( @@ -772,7 +771,7 @@ function initialCooldown(updatedAt: Date | string): number { return remaining > 0 ? remaining : 0; } -function ResendButton({ invite }: { invite: Invite }) { +function ResendButton({ invite, canManageMembers }: { invite: Invite; canManageMembers: boolean }) { const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting" && @@ -806,12 +805,17 @@ function ResendButton({ invite }: { invite: Invite }) { return () => clearInterval(intervalRef.current); }, [cooldownActive]); - const isDisabled = isSubmitting || cooldown > 0; + const isDisabled = isSubmitting || cooldown > 0 || !canManageMembers; return (
- + ) + ) : triggerButton ? ( + cloneElement(triggerButton, { disabled: true, tooltip: noBillingTooltip }) + ) : ( + + {title} + + ); + return ( - - {triggerButton ?? ( - - )} - + {trigger} {title} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx index a24a857d6f6..37e44330496 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx @@ -1,7 +1,9 @@ -import { Outlet } from "@remix-run/react"; +import { Outlet, useRouteLoaderData } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { VERSION as coreVersion } from "@trigger.dev/core"; +import { type ReactNode } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { RouteErrorDisplay } from "~/components/ErrorDisplay"; import { AppContainer, MainBody } from "~/components/layout/AppLayout"; import { type BuildInfo, @@ -10,6 +12,8 @@ import { import { useOrganization } from "~/hooks/useOrganizations"; import { rbac } from "~/services/rbac.server"; +const SETTINGS_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings"; + export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ buildInfo: { @@ -23,8 +27,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); }; -export default function Page() { - const { buildInfo, isUsingPlugin } = useTypedLoaderData(); +function SettingsChrome({ + buildInfo, + isUsingPlugin, + children, +}: { + buildInfo: BuildInfo; + isUsingPlugin: boolean; + children: ReactNode; +}) { const organization = useOrganization(); return ( @@ -35,10 +46,38 @@ export default function Page() { buildInfo={buildInfo} isUsingPlugin={isUsingPlugin} /> - - - + {children} ); } + +export default function Page() { + const { buildInfo, isUsingPlugin } = useTypedLoaderData(); + + return ( + + + + ); +} + +// Reconstruct the settings chrome so a permission denial or error on a settings +// page renders in the content pane with the settings nav intact. This route's +// loader has already run (the error comes from a child route), so its data is +// available via useRouteLoaderData. +export function ErrorBoundary() { + const data = useRouteLoaderData(SETTINGS_ROUTE_ID) as + | { buildInfo: BuildInfo; isUsingPlugin: boolean } + | undefined; + + if (!data) { + return ; + } + + return ( + + + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx index f5402559bde..f267b8bf715 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx @@ -1,48 +1,60 @@ -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { BackgroundWrapper } from "~/components/BackgroundWrapper"; import { AppContainer, MainBody, PageBody } from "~/components/layout/AppLayout"; import { Header1 } from "~/components/primitives/Headers"; import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { getCurrentPlan, getPlans } from "~/services/platform.v3.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder"; import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan"; -export async function loader({ params, request }: LoaderFunctionArgs) { - await requireUserId(request); - const { organizationSlug } = OrganizationParamsSchema.parse(params); +export const loader = dashboardLoader( + { + params: OrganizationParamsSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "manage", resource: { type: "billing" } }, + // Full-screen subscribe gate outside the org layout: keep redirecting on + // denial rather than throwing the permission panel. + unauthorizedRedirect: "/", + }, + async ({ params, request }) => { + const { organizationSlug } = params; - const { isManagedCloud } = featuresForRequest(request); - if (!isManagedCloud) { - return redirect(organizationPath({ slug: organizationSlug })); - } + const { isManagedCloud } = featuresForRequest(request); + if (!isManagedCloud) { + return redirect(organizationPath({ slug: organizationSlug })); + } - const plans = await getPlans(); - if (!plans) { - throw new Response(null, { status: 404, statusText: "Plans not found" }); - } + const plans = await getPlans(); + if (!plans) { + throw new Response(null, { status: 404, statusText: "Plans not found" }); + } - const organization = await prisma.organization.findUnique({ - where: { slug: organizationSlug }, - }); + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug }, + }); - if (!organization) { - throw new Response(null, { status: 404, statusText: "Organization not found" }); - } + if (!organization) { + throw new Response(null, { status: 404, statusText: "Organization not found" }); + } - if (organization.v3Enabled) { - return redirect(organizationPath({ slug: organizationSlug })); - } + if (organization.v3Enabled) { + return redirect(organizationPath({ slug: organizationSlug })); + } - const currentPlan = await getCurrentPlan(organization.id); + const currentPlan = await getCurrentPlan(organization.id); - const periodEnd = new Date(); - periodEnd.setMonth(periodEnd.getMonth() + 1); + const periodEnd = new Date(); + periodEnd.setMonth(periodEnd.getMonth() + 1); - return typedjson({ ...plans, ...currentPlan, organizationSlug, periodEnd }); -} + return typedjson({ ...plans, ...currentPlan, organizationSlug, periodEnd }); + } +); export default function ChoosePlanPage() { const { plans, v3Subscription, organizationSlug, periodEnd, addOnPricing } = diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts index b9cb61e1f20..1f455a6b51c 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts @@ -6,6 +6,7 @@ import { authenticateRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -49,6 +50,20 @@ export async function action({ request, params }: ActionFunctionArgs) { triggerBranch ); + // This mints a JWT signed with the environment's secret key. For a PAT + // (a user), gate it on env-tier read:apiKeys so a restricted role can't + // obtain deployed-environment credentials (and therefore can't deploy). + const denied = await authorizePatEnvironmentAccess({ + request, + authType: authenticationResult.type, + organizationId: runtimeEnv.organizationId, + projectId: runtimeEnv.project.id, + envType: runtimeEnv.type, + resource: "apiKeys", + action: "read", + }); + if (denied) return denied; + const parsedBody = RequestBodySchema.safeParse(await request.json()); if (!parsedBody.success) { diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts index 218cc580dd3..42ef412ec19 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts @@ -8,6 +8,7 @@ import { branchNameFromRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -26,7 +27,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { projectRef, env } = parsedParams.data; try { - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -39,6 +44,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { branchNameFromRequest(request) ); + // This endpoint hands the caller the environment's secret key. For a PAT + // (a user), gate it on env-tier read:apiKeys — so a restricted role can't + // pull deployed credentials (and therefore can't deploy) via the CLI. + const denied = await authorizePatEnvironmentAccess({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + resource: "apiKeys", + action: "read", + }); + if (denied) return denied; + const result: GetProjectEnvResponse = { apiKey: environment.apiKey, name: environment.project.name, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts index 00e155622ce..4c0fa99185a 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts @@ -8,6 +8,7 @@ import { branchNameFromRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -24,7 +25,11 @@ export async function action({ params, request }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -37,6 +42,16 @@ export async function action({ params, request }: ActionFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "write", + }); + if (denied) return denied; + // Find the environment variable const variable = await prisma.environmentVariable.findFirst({ where: { @@ -110,7 +125,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -123,6 +142,16 @@ export async function loader({ params, request }: LoaderFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "read", + }); + if (denied) return denied; + // Find the environment variable const variable = await prisma.environmentVariable.findFirst({ where: { diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts index 313ecdc8538..177fbd6848f 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts @@ -7,6 +7,7 @@ import { authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; +import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -21,7 +22,11 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -34,6 +39,16 @@ export async function action({ params, request }: ActionFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "write", + }); + if (denied) return denied; + const repository = new EnvironmentVariablesRepository(); const body = await parseImportBody(request); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index 188290ae8ab..95e1d480fb8 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -6,6 +6,7 @@ import { authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; +import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -20,7 +21,11 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -33,6 +38,16 @@ export async function action({ params, request }: ActionFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "write", + }); + if (denied) return denied; + const jsonBody = await request.json(); const body = CreateEnvironmentVariableRequestBody.safeParse(jsonBody); @@ -68,7 +83,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request); + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: true, + }); if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); @@ -81,6 +100,16 @@ export async function loader({ params, request }: LoaderFunctionArgs) { branchNameFromRequest(request) ); + const denied = await authorizeEnvVarApiRequest({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + action: "read", + }); + if (denied) return denied; + const repository = new EnvironmentVariablesRepository(); const variables = await repository.getEnvironmentWithRedactedSecrets( diff --git a/apps/webapp/app/routes/invite-resend.tsx b/apps/webapp/app/routes/invite-resend.tsx index 5dc285b944f..9d3f8363653 100644 --- a/apps/webapp/app/routes/invite-resend.tsx +++ b/apps/webapp/app/routes/invite-resend.tsx @@ -1,54 +1,71 @@ import { parse } from "@conform-to/zod"; -import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; import { env } from "process"; import { z } from "zod"; +import { $replica } from "~/db.server"; import { resendInvite } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { scheduleEmail } from "~/services/scheduleEmail.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder"; export const resendSchema = z.object({ inviteId: z.string(), }); -export const action: ActionFunction = async ({ request }) => { - const userId = await requireUserId(request); - - const formData = await request.formData(); - const submission = parse(formData, { schema: resendSchema }); - - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } +export const action = dashboardAction( + { + // No URL params — resolve the org for the auth scope from the invite + // referenced in the form body. Read it off a clone so the handler can + // still parse the original request. + context: async (_params, request) => { + const form = await request.clone().formData(); + const inviteId = form.get("inviteId"); + if (typeof inviteId !== "string") return {}; + const invite = await $replica.orgMemberInvite.findFirst({ + where: { id: inviteId }, + select: { organizationId: true }, + }); + return invite ? { organizationId: invite.organizationId } : {}; + }, + authorization: { action: "manage", resource: { type: "members" } }, + }, + async ({ request, user }) => { + const formData = await request.formData(); + const submission = parse(formData, { schema: resendSchema }); - try { - const invite = await resendInvite({ - inviteId: submission.value.inviteId, - userId, - }); + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } try { - await scheduleEmail({ - email: "invite", - to: invite.email, - orgName: invite.organization.title, - inviterName: invite.inviter.name ?? undefined, - inviterEmail: invite.inviter.email, - inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`, + const invite = await resendInvite({ + inviteId: submission.value.inviteId, + userId: user.id, }); - } catch (error) { - console.error("Failed to send invite email"); - console.error(error); - throw new Error("Failed to send invite email"); - } - return redirectWithSuccessMessage( - organizationTeamPath(invite.organization), - request, - `Invite resent to ${invite.email}` - ); - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + try { + await scheduleEmail({ + email: "invite", + to: invite.email, + orgName: invite.organization.title, + inviterName: invite.inviter.name ?? undefined, + inviterEmail: invite.inviter.email, + inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`, + }); + } catch (error) { + console.error("Failed to send invite email"); + console.error(error); + throw new Error("Failed to send invite email"); + } + + return redirectWithSuccessMessage( + organizationTeamPath(invite.organization), + request, + `Invite resent to ${invite.email}` + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } } -}; +); diff --git a/apps/webapp/app/routes/invite-revoke.tsx b/apps/webapp/app/routes/invite-revoke.tsx index cd499e58dc3..457a69eafeb 100644 --- a/apps/webapp/app/routes/invite-revoke.tsx +++ b/apps/webapp/app/routes/invite-revoke.tsx @@ -1,9 +1,10 @@ import { parse } from "@conform-to/zod"; -import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; import { z } from "zod"; +import { $replica } from "~/db.server"; import { revokeInvite } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { organizationTeamPath } from "~/utils/pathBuilder"; export const revokeSchema = z.object({ @@ -11,29 +12,45 @@ export const revokeSchema = z.object({ slug: z.string(), }); -export const action: ActionFunction = async ({ request }) => { - const userId = await requireUserId(request); +export const action = dashboardAction( + { + // No URL params — resolve the org for the auth scope from the `slug` + // in the form body. Read it off a clone so the handler can still parse + // the original request. + context: async (_params, request) => { + const form = await request.clone().formData(); + const slug = form.get("slug"); + if (typeof slug !== "string") return {}; + const org = await $replica.organization.findFirst({ + where: { slug }, + select: { id: true }, + }); + return org ? { organizationId: org.id } : {}; + }, + authorization: { action: "manage", resource: { type: "members" } }, + }, + async ({ request, user }) => { + const formData = await request.formData(); + const submission = parse(formData, { schema: revokeSchema }); - const formData = await request.formData(); - const submission = parse(formData, { schema: revokeSchema }); + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } - - try { - const { email, organization } = await revokeInvite({ - userId, - orgSlug: submission.value.slug, - inviteId: submission.value.inviteId, - }); + try { + const { email, organization } = await revokeInvite({ + userId: user.id, + orgSlug: submission.value.slug, + inviteId: submission.value.inviteId, + }); - return redirectWithSuccessMessage( - organizationTeamPath(organization), - request, - `Invite revoked for ${email}` - ); - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + return redirectWithSuccessMessage( + organizationTeamPath(organization), + request, + `Invite revoked for ${email}` + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } } -}; +); diff --git a/apps/webapp/app/routes/resources.$organizationSlug.subscription.portal.ts b/apps/webapp/app/routes/resources.$organizationSlug.subscription.portal.ts index 47ab9d5c666..aec7b0bd929 100644 --- a/apps/webapp/app/routes/resources.$organizationSlug.subscription.portal.ts +++ b/apps/webapp/app/routes/resources.$organizationSlug.subscription.portal.ts @@ -1,45 +1,57 @@ -import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "remix-typedjson"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { customerPortalUrl } from "~/services/platform.v3.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { OrganizationParamsSchema, v3BillingPath } from "~/utils/pathBuilder"; -export async function loader({ request, params }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const { organizationSlug } = OrganizationParamsSchema.parse(params); - - const org = await prisma.organization.findUnique({ - select: { - id: true, +export const loader = dashboardLoader( + { + params: OrganizationParamsSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; }, - where: { - slug: organizationSlug, - members: { - some: { - userId, + authorization: { action: "manage", resource: { type: "billing" } }, + // Redirect endpoint (no UI): keep redirecting on denial rather than + // throwing the permission panel. + unauthorizedRedirect: "/", + }, + async ({ request, params, user }) => { + const { organizationSlug } = params; + + const org = await prisma.organization.findFirst({ + select: { + id: true, + }, + where: { + slug: organizationSlug, + members: { + some: { + userId: user.id, + }, }, }, - }, - }); + }); - if (!org) { - return redirectWithErrorMessage( - v3BillingPath({ slug: organizationSlug }), - request, - "Something went wrong. Please try again later." - ); - } + if (!org) { + return redirectWithErrorMessage( + v3BillingPath({ slug: organizationSlug }), + request, + "Something went wrong. Please try again later." + ); + } - const result = await customerPortalUrl(org.id, organizationSlug); - if (!result || !result.success || !result.customerPortalUrl) { - return redirectWithErrorMessage( - v3BillingPath({ slug: organizationSlug }), - request, - "Something went wrong. Please try again later." - ); - } + const result = await customerPortalUrl(org.id, organizationSlug); + if (!result || !result.success || !result.customerPortalUrl) { + return redirectWithErrorMessage( + v3BillingPath({ slug: organizationSlug }), + request, + "Something went wrong. Please try again later." + ); + } - return redirect(result.customerPortalUrl); -} + return redirect(result.customerPortalUrl); + } +); diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts index c802d115ad1..ffc7e4315c6 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts @@ -1,11 +1,11 @@ import { parse } from "@conform-to/zod"; -import { type ActionFunction, json } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { errAsync, fromPromise, okAsync } from "neverthrow"; import { z } from "zod"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { DeploymentService } from "~/v3/services/deployment.server"; export const cancelSchema = z.object({ @@ -17,117 +17,142 @@ const ParamSchema = z.object({ deploymentShortCode: z.string(), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { projectId, deploymentShortCode } = ParamSchema.parse(params); +async function resolveOrgIdFromProjectId(projectId: string): Promise { + const project = await $replica.project.findFirst({ + where: { id: projectId }, + select: { organizationId: true }, + }); + return project?.organizationId ?? null; +} - const formData = await request.formData(); - const submission = parse(formData, { schema: cancelSchema }); +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromProjectId(params.projectId); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "deployments" } }, + }, + async ({ request, params, user }) => { + const { projectId, deploymentShortCode } = params; + const userId = user.id; - if (!submission.value) { - return json(submission); - } + const formData = await request.formData(); + const submission = parse(formData, { schema: cancelSchema }); + + if (!submission.value) { + return json(submission); + } - const verifyProjectMembership = () => - fromPromise( - prisma.project.findFirst({ - where: { - id: projectId, - organization: { - members: { - some: { - userId, + const verifyProjectMembership = () => + fromPromise( + prisma.project.findFirst({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, }, }, }, - }, - select: { - id: true, - }, - }), - (error) => ({ type: "other" as const, cause: error }) - ).andThen((project) => { - if (!project) { - return errAsync({ type: "project_not_found" as const }); - } - return okAsync(project); - }); + select: { + id: true, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ).andThen((project) => { + if (!project) { + return errAsync({ type: "project_not_found" as const }); + } + return okAsync(project); + }); - const findDeploymentFriendlyId = ({ id }: { id: string }) => - fromPromise( - prisma.workerDeployment.findUnique({ - select: { - friendlyId: true, - projectId: true, - }, - where: { - projectId_shortCode: { - projectId: id, - shortCode: deploymentShortCode, + const findDeploymentFriendlyId = ({ id }: { id: string }) => + fromPromise( + prisma.workerDeployment.findUnique({ + select: { + friendlyId: true, + projectId: true, }, - }, - }), - (error) => ({ type: "other" as const, cause: error }) - ).andThen((deployment) => { - if (!deployment) { - return errAsync({ type: "deployment_not_found" as const }); - } - return okAsync(deployment); - }); + where: { + projectId_shortCode: { + projectId: id, + shortCode: deploymentShortCode, + }, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ).andThen((deployment) => { + if (!deployment) { + return errAsync({ type: "deployment_not_found" as const }); + } + return okAsync(deployment); + }); - const deploymentService = new DeploymentService(); - const result = await verifyProjectMembership() - .andThen(findDeploymentFriendlyId) - .andThen((deployment) => - deploymentService.cancelDeployment({ projectId: deployment.projectId }, deployment.friendlyId) - ); + const deploymentService = new DeploymentService(); + const result = await verifyProjectMembership() + .andThen(findDeploymentFriendlyId) + .andThen((deployment) => + deploymentService.cancelDeployment( + { projectId: deployment.projectId }, + deployment.friendlyId + ) + ); - if (result.isErr()) { - logger.error( - `Failed to cancel deployment: ${result.error.type}`, - result.error.type === "other" - ? { - cause: result.error.cause, - } - : undefined - ); + if (result.isErr()) { + logger.error( + `Failed to cancel deployment: ${result.error.type}`, + result.error.type === "other" + ? { + cause: result.error.cause, + } + : undefined + ); - switch (result.error.type) { - case "project_not_found": - return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); - case "deployment_not_found": - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - "Deployment not found" - ); - case "deployment_cannot_be_cancelled": - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - "Deployment is already in a final state and cannot be canceled" - ); - case "failed_to_delete_deployment_timeout": - // not a critical error, ignore - return redirectWithSuccessMessage( - submission.value.redirectUrl, - request, - `Canceled deployment ${deploymentShortCode}.` - ); - case "other": - default: - result.error.type satisfies "other"; - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - "Internal server error" - ); + switch (result.error.type) { + case "project_not_found": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Project not found" + ); + case "deployment_not_found": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment not found" + ); + case "deployment_cannot_be_cancelled": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment is already in a final state and cannot be canceled" + ); + case "failed_to_delete_deployment_timeout": + // not a critical error, ignore + return redirectWithSuccessMessage( + submission.value.redirectUrl, + request, + `Canceled deployment ${deploymentShortCode}.` + ); + case "other": + default: + result.error.type satisfies "other"; + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Internal server error" + ); + } } - } - return redirectWithSuccessMessage( - submission.value.redirectUrl, - request, - `Canceled deployment ${deploymentShortCode}.` - ); -}; + return redirectWithSuccessMessage( + submission.value.redirectUrl, + request, + `Canceled deployment ${deploymentShortCode}.` + ); + } +); diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts index 1d96df89d1e..734f7c8259e 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.promote.ts @@ -1,10 +1,10 @@ import { parse } from "@conform-to/zod"; -import { ActionFunction, json } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { z } from "zod"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server"; export const promoteSchema = z.object({ @@ -16,75 +16,90 @@ const ParamSchema = z.object({ deploymentShortCode: z.string(), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { projectId, deploymentShortCode } = ParamSchema.parse(params); +async function resolveOrgIdFromProjectId(projectId: string): Promise { + const project = await $replica.project.findFirst({ + where: { id: projectId }, + select: { organizationId: true }, + }); + return project?.organizationId ?? null; +} - const formData = await request.formData(); - const submission = parse(formData, { schema: promoteSchema }); +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromProjectId(params.projectId); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "deployments" } }, + }, + async ({ request, params, user }) => { + const { projectId, deploymentShortCode } = params; - if (!submission.value) { - return json(submission); - } + const formData = await request.formData(); + const submission = parse(formData, { schema: promoteSchema }); - try { - const project = await prisma.project.findUnique({ - where: { - id: projectId, - organization: { - members: { - some: { - userId, + if (!submission.value) { + return json(submission); + } + + try { + const project = await prisma.project.findFirst({ + where: { + id: projectId, + organization: { + members: { + some: { + userId: user.id, + }, }, }, }, - }, - }); + }); - if (!project) { - return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); - } + if (!project) { + return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); + } - const deployment = await prisma.workerDeployment.findUnique({ - where: { - projectId_shortCode: { + const deployment = await prisma.workerDeployment.findFirst({ + where: { projectId: project.id, shortCode: deploymentShortCode, }, - }, - }); + }); + + if (!deployment) { + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment not found" + ); + } + + const promoteService = new ChangeCurrentDeploymentService(); + await promoteService.call(deployment, "promote"); - if (!deployment) { - return redirectWithErrorMessage( + return redirectWithSuccessMessage( submission.value.redirectUrl, request, - "Deployment not found" + `Promoted deployment version ${deployment.version} to current.` ); - } - - const promoteService = new ChangeCurrentDeploymentService(); - await promoteService.call(deployment, "promote"); - - return redirectWithSuccessMessage( - submission.value.redirectUrl, - request, - `Promoted deployment version ${deployment.version} to current.` - ); - } catch (error) { - if (error instanceof Error) { - logger.error("Failed to promote deployment", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - }); - submission.error = { runParam: [error.message] }; - return json(submission); - } else { - logger.error("Failed to promote deployment", { error }); - submission.error = { runParam: [JSON.stringify(error)] }; - return json(submission); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to promote deployment", { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + submission.error = { "": [error.message] }; + return json(submission); + } else { + logger.error("Failed to promote deployment", { error }); + submission.error = { "": [JSON.stringify(error)] }; + return json(submission); + } } } -}; +); diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts index 9995ba4c063..d7387185ffa 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts @@ -1,10 +1,10 @@ import { parse } from "@conform-to/zod"; -import { ActionFunction, json } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { z } from "zod"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server"; export const rollbackSchema = z.object({ @@ -16,78 +16,90 @@ const ParamSchema = z.object({ deploymentShortCode: z.string(), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { projectId, deploymentShortCode } = ParamSchema.parse(params); +async function resolveOrgIdFromProjectId(projectId: string): Promise { + const project = await $replica.project.findFirst({ + where: { id: projectId }, + select: { organizationId: true }, + }); + return project?.organizationId ?? null; +} - console.log("projectId", projectId); - console.log("deploymentShortCode", deploymentShortCode); +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromProjectId(params.projectId); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "deployments" } }, + }, + async ({ request, params, user }) => { + const { projectId, deploymentShortCode } = params; - const formData = await request.formData(); - const submission = parse(formData, { schema: rollbackSchema }); + const formData = await request.formData(); + const submission = parse(formData, { schema: rollbackSchema }); - if (!submission.value) { - return json(submission); - } + if (!submission.value) { + return json(submission); + } - try { - const project = await prisma.project.findUnique({ - where: { - id: projectId, - organization: { - members: { - some: { - userId, + try { + const project = await prisma.project.findFirst({ + where: { + id: projectId, + organization: { + members: { + some: { + userId: user.id, + }, }, }, }, - }, - }); + }); - if (!project) { - return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); - } + if (!project) { + return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); + } - const deployment = await prisma.workerDeployment.findUnique({ - where: { - projectId_shortCode: { + const deployment = await prisma.workerDeployment.findFirst({ + where: { projectId: project.id, shortCode: deploymentShortCode, }, - }, - }); + }); + + if (!deployment) { + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment not found" + ); + } + + const rollbackService = new ChangeCurrentDeploymentService(); + await rollbackService.call(deployment, "rollback"); - if (!deployment) { - return redirectWithErrorMessage( + return redirectWithSuccessMessage( submission.value.redirectUrl, request, - "Deployment not found" + "Rolled back deployment" ); - } - - const rollbackService = new ChangeCurrentDeploymentService(); - await rollbackService.call(deployment, "rollback"); - - return redirectWithSuccessMessage( - submission.value.redirectUrl, - request, - "Rolled back deployment" - ); - } catch (error) { - if (error instanceof Error) { - logger.error("Failed to roll back deployment", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - }); - submission.error = { runParam: [error.message] }; - return json(submission); - } else { - logger.error("Failed to roll back deployment", { error }); - submission.error = { runParam: [JSON.stringify(error)] }; - return json(submission); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to roll back deployment", { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + submission.error = { "": [error.message] }; + return json(submission); + } else { + logger.error("Failed to roll back deployment", { error }); + submission.error = { "": [JSON.stringify(error)] }; + return json(submission); + } } } -}; +); diff --git a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx index 5efb69bc723..ced2145f212 100644 --- a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx +++ b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx @@ -1,56 +1,86 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; +import { $replica } from "~/db.server"; import { regenerateApiKey } from "~/models/api-key.server"; -import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server"; -import { requireUserId } from "~/services/session.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { logger } from "~/services/logger.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; const ParamsSchema = z.object({ environmentId: z.string(), }); -export async function action({ request, params }: ActionFunctionArgs) { - // Ensure this is a POST request - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } - - const userId = await requireUserId(request); - - const { environmentId } = ParamsSchema.parse(params); +export const action = dashboardAction( + { + params: ParamsSchema, + context: async (params) => { + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { id: params.environmentId }, + select: { organizationId: true }, + }); + return environment ? { organizationId: environment.organizationId } : {}; + }, + // Env-tier write:apiKeys is enforced in the handler — the target + // environment's tier isn't known until we resolve it from the id. + }, + async ({ request, params, user, ability }) => { + if (request.method.toUpperCase() !== "POST") { + throw new Response("Method Not Allowed", { status: 405 }); + } - const formData = await request.formData(); - const syncToVercel = formData.get("syncToVercel") === "on"; + const { environmentId } = params; - try { - const updatedEnvironment = await regenerateApiKey({ userId, environmentId }); + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { id: environmentId }, + select: { type: true }, + }); + if (!environment) { + return jsonWithErrorMessage({ ok: false }, request, "Environment not found"); + } - // Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT - if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") { - await syncApiKeyToVercel( - updatedEnvironment.projectId, - updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW", - updatedEnvironment.apiKey + // Gate the regenerate even on a direct POST: a role that can't write + // this tier's API keys can't rotate them. The disabled UI control is + // not the boundary; this check is. + if (!ability.can("write", { type: "apiKeys", envType: environment.type })) { + return jsonWithErrorMessage( + { ok: false }, + request, + "You don't have permission to regenerate API keys for this environment." ); } - return jsonWithSuccessMessage( - { ok: true }, - request, - `API keys regenerated for ${environmentFullTitle(updatedEnvironment)} environment` - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; + const formData = await request.formData(); + const syncToVercel = formData.get("syncToVercel") === "on"; - return jsonWithErrorMessage( - { ok: false }, - request, - `API keys could not be regenerated: ${message}` - ); + try { + const updatedEnvironment = await regenerateApiKey({ userId: user.id, environmentId }); + + // Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT + if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") { + await syncApiKeyToVercel( + updatedEnvironment.projectId, + updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW", + updatedEnvironment.apiKey + ); + } + + return jsonWithSuccessMessage( + { ok: true }, + request, + `API keys regenerated for ${environmentFullTitle(updatedEnvironment)} environment` + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + + return jsonWithErrorMessage( + { ok: false }, + request, + `API keys could not be regenerated: ${message}` + ); + } } -} +); /** * Sync the API key to Vercel. diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index fe1b32f8925..d0f662b2b76 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -1,15 +1,22 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { Form, useActionData, useNavigation, useNavigate, useSearchParams, useLocation } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { redirect, -typedjson, useTypedFetcher } from "remix-typedjson"; +import { + Form, + useActionData, + useNavigation, + useNavigate, + useSearchParams, + useLocation, +} from "@remix-run/react"; +import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { redirect, typedjson, useTypedFetcher } from "remix-typedjson"; import { z } from "zod"; import { OctoKitty } from "~/components/GitHubLoginButton"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { DialogClose } from "@radix-ui/react-dialog"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { PermissionLink } from "~/components/primitives/PermissionLink"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; @@ -36,6 +43,7 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage, } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; @@ -43,6 +51,8 @@ import { logger } from "~/services/logger.server"; import { triggerInitialDeployment } from "~/services/platform.v3.server"; import { VercelIntegrationService } from "~/services/vercelIntegration.server"; import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { githubAppInstallPath, EnvironmentParamSchema, @@ -141,7 +151,17 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw new Response("Failed to load GitHub settings", { status: 500 }); } - return typedjson(resultOrFail.value); + // Display flag for the connect/disconnect/configure controls — the action + // enforces write:github independently. Permissive in OSS. + const sessionAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const canManageGithub = sessionAuth.ok + ? sessionAuth.ability.can("write", { type: "github" }) + : true; + + return typedjson({ ...resultOrFail.value, canManageGithub }); } // ============================================================================ @@ -164,170 +184,183 @@ function redirectWithMessage( : redirectBackWithErrorMessage(request, message); } -export async function action({ request, params }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "github" } }, + }, + async ({ request, params, user }) => { + const userId = user.id; + const { organizationSlug, projectParam, envParam } = params; + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - const formData = await request.formData(); - const submission = parse(formData, { schema: GitHubActionSchema }); + const formData = await request.formData(); + const submission = parse(formData, { schema: GitHubActionSchema }); - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } - const projectSettingsService = new ProjectSettingsService(); - const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( - organizationSlug, - projectParam, - userId - ); + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); - if (membershipResultOrFail.isErr()) { - return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); - } + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } - const { projectId, organizationId } = membershipResultOrFail.value; - const { action: actionType } = submission.value; + const { projectId, organizationId } = membershipResultOrFail.value; + const { action: actionType } = submission.value; - // Handle connect-repo action - if (actionType === "connect-repo") { - const { repositoryId, installationId, redirectUrl } = submission.value; + // Handle connect-repo action + if (actionType === "connect-repo") { + const { repositoryId, installationId, redirectUrl } = submission.value; - const resultOrFail = await projectSettingsService.connectGitHubRepo( - projectId, - organizationId, - repositoryId, - installationId - ); + const resultOrFail = await projectSettingsService.connectGitHubRepo( + projectId, + organizationId, + repositoryId, + installationId + ); - if (resultOrFail.isOk()) { - // Trigger initial deployment for marketplace flows now that GitHub is connected. - // We check the persisted onboardingOrigin on the Vercel integration rather than - // the redirectUrl, because the redirect URL loses the marketplace context when - // the user installs the GitHub App for the first time (full-page redirect cycle). - try { - const vercelService = new VercelIntegrationService(); - const vercelIntegration = await vercelService.getVercelProjectIntegration(projectId); - if ( - vercelIntegration?.parsedIntegrationData.onboardingCompleted && - vercelIntegration.parsedIntegrationData.onboardingOrigin === "marketplace" - ) { - logger.info("Marketplace flow detected, triggering initial deployment", { projectId }); - await triggerInitialDeployment(projectId, { environment: "prod" }); + if (resultOrFail.isOk()) { + // Trigger initial deployment for marketplace flows now that GitHub is connected. + // We check the persisted onboardingOrigin on the Vercel integration rather than + // the redirectUrl, because the redirect URL loses the marketplace context when + // the user installs the GitHub App for the first time (full-page redirect cycle). + try { + const vercelService = new VercelIntegrationService(); + const vercelIntegration = await vercelService.getVercelProjectIntegration(projectId); + if ( + vercelIntegration?.parsedIntegrationData.onboardingCompleted && + vercelIntegration.parsedIntegrationData.onboardingOrigin === "marketplace" + ) { + logger.info("Marketplace flow detected, triggering initial deployment", { projectId }); + await triggerInitialDeployment(projectId, { environment: "prod" }); + } + } catch (error) { + logger.error("Failed to check Vercel integration or trigger initial deployment", { + projectId, + error, + }); } - } catch (error) { - logger.error("Failed to check Vercel integration or trigger initial deployment", { projectId, error }); + + return redirectWithMessage( + request, + redirectUrl, + "GitHub repository connected successfully", + "success" + ); } - return redirectWithMessage( - request, - redirectUrl, - "GitHub repository connected successfully", - "success" - ); - } + const errorType = resultOrFail.error.type; - const errorType = resultOrFail.error.type; + if (errorType === "gh_repository_not_found") { + return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error"); + } - if (errorType === "gh_repository_not_found") { - return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error"); - } + if (errorType === "project_already_has_connected_repository") { + return redirectWithMessage( + request, + redirectUrl, + "Project already has a connected repository", + "error" + ); + } - if (errorType === "project_already_has_connected_repository") { + logger.error("Failed to connect GitHub repository", { error: resultOrFail.error }); return redirectWithMessage( request, redirectUrl, - "Project already has a connected repository", + "Failed to connect GitHub repository", "error" ); } - logger.error("Failed to connect GitHub repository", { error: resultOrFail.error }); - return redirectWithMessage( - request, - redirectUrl, - "Failed to connect GitHub repository", - "error" - ); - } + // Handle disconnect-repo action + if (actionType === "disconnect-repo") { + const { redirectUrl } = submission.value; - // Handle disconnect-repo action - if (actionType === "disconnect-repo") { - const { redirectUrl } = submission.value; + const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId); - const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId); + if (resultOrFail.isOk()) { + return redirectWithMessage( + request, + redirectUrl, + "GitHub repository disconnected successfully", + "success" + ); + } - if (resultOrFail.isOk()) { + logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error }); return redirectWithMessage( request, redirectUrl, - "GitHub repository disconnected successfully", - "success" + "Failed to disconnect GitHub repository", + "error" ); } - logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error }); - return redirectWithMessage( - request, - redirectUrl, - "Failed to disconnect GitHub repository", - "error" - ); - } + // Handle update-git-settings action + if (actionType === "update-git-settings") { + const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } = + submission.value; - // Handle update-git-settings action - if (actionType === "update-git-settings") { - const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } = - submission.value; + const resultOrFail = await projectSettingsService.updateGitSettings( + projectId, + productionBranch, + stagingBranch, + previewDeploymentsEnabled + ); - const resultOrFail = await projectSettingsService.updateGitSettings( - projectId, - productionBranch, - stagingBranch, - previewDeploymentsEnabled - ); + if (resultOrFail.isOk()) { + return redirectWithMessage( + request, + redirectUrl, + "Git settings updated successfully", + "success" + ); + } - if (resultOrFail.isOk()) { - return redirectWithMessage( - request, - redirectUrl, - "Git settings updated successfully", - "success" - ); - } + const errorType = resultOrFail.error.type; - const errorType = resultOrFail.error.type; + const errorMessages: Record = { + github_app_not_enabled: "GitHub app is not enabled", + connected_gh_repository_not_found: "Connected GitHub repository not found", + production_tracking_branch_not_found: "Production tracking branch not found", + staging_tracking_branch_not_found: "Staging tracking branch not found", + }; - const errorMessages: Record = { - github_app_not_enabled: "GitHub app is not enabled", - connected_gh_repository_not_found: "Connected GitHub repository not found", - production_tracking_branch_not_found: "Production tracking branch not found", - staging_tracking_branch_not_found: "Staging tracking branch not found", - }; + const message = errorMessages[errorType]; + if (message) { + return redirectWithMessage(request, redirectUrl, message, "error"); + } - const message = errorMessages[errorType]; - if (message) { - return redirectWithMessage(request, redirectUrl, message, "error"); + logger.error("Failed to update Git settings", { error: resultOrFail.error }); + return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error"); } - logger.error("Failed to update Git settings", { error: resultOrFail.error }); - return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error"); + // Exhaustive check - this should never be reached + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); } - - // Exhaustive check - this should never be reached - submission.value satisfies never; - return redirectBackWithErrorMessage(request, "Failed to process request"); -} +); // ============================================================================ // Helper: Build resource URL for fetching GitHub data @@ -352,6 +385,7 @@ export function ConnectGitHubRepoModal({ environmentSlug, redirectUrl, preventDismiss, + canManageGithub = true, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; @@ -360,6 +394,7 @@ export function ConnectGitHubRepoModal({ redirectUrl?: string; /** When true, prevents closing the modal via Escape key or clicking outside */ preventDismiss?: boolean; + canManageGithub?: boolean; }) { const [isModalOpen, setIsModalOpen] = useState(false); const lastSubmission = useActionData() as any; @@ -420,7 +455,17 @@ export function ConnectGitHubRepoModal({ }} > - @@ -580,20 +625,29 @@ export function GitHubConnectionPrompt({ projectSlug, environmentSlug, redirectUrl, + canManageGithub = true, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; projectSlug: string; environmentSlug: string; redirectUrl?: string; + canManageGithub?: boolean; }) { - - const githubInstallationRedirect = redirectUrl || v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug }); + const githubInstallationRedirect = + redirectUrl || + v3ProjectSettingsIntegrationsPath( + { slug: organizationSlug }, + { slug: projectSlug }, + { slug: environmentSlug } + ); return (
{gitHubAppInstallations.length === 0 && ( - Install GitHub app - + )} {gitHubAppInstallations.length !== 0 && (
@@ -612,6 +666,7 @@ export function GitHubConnectionPrompt({ projectSlug={projectSlug} environmentSlug={environmentSlug} redirectUrl={redirectUrl} + canManageGithub={canManageGithub} /> GitHub app is installed @@ -631,6 +686,7 @@ export function ConnectedGitHubRepoForm({ environmentSlug, billingPath, redirectUrl, + canManageGithub = true, }: { connectedGitHubRepo: ConnectedGitHubRepo; previewEnvironmentEnabled?: boolean; @@ -639,6 +695,7 @@ export function ConnectedGitHubRepoForm({ environmentSlug: string; billingPath: string; redirectUrl?: string; + canManageGithub?: boolean; }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); @@ -705,7 +762,17 @@ export function ConnectedGitHubRepoForm({
- + Disconnect GitHub repository @@ -834,7 +901,12 @@ export function ConnectedGitHubRepoForm({ name="action" value="update-git-settings" variant="secondary/small" - disabled={isGitSettingsLoading || !hasGitSettingsChanges} + disabled={isGitSettingsLoading || !hasGitSettingsChanges || !canManageGithub} + tooltip={ + canManageGithub + ? undefined + : "You don't have permission to manage the GitHub integration" + } LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined} > Save @@ -905,6 +977,7 @@ export function GitHubSettingsPanel({ environmentSlug={environmentSlug} billingPath={billingPath} redirectUrl={effectiveRedirectUrl} + canManageGithub={data.canManageGithub} /> ); } @@ -918,13 +991,11 @@ export function GitHubSettingsPanel({ projectSlug={projectSlug} environmentSlug={environmentSlug} redirectUrl={effectiveRedirectUrl} + canManageGithub={data.canManageGithub} /> {!data.connectedRepository && ( - - Connect your GitHub repository to automatically deploy your changes. - + Connect your GitHub repository to automatically deploy your changes. )} - ); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index ab216bcab7e..483e0ea5725 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -2,7 +2,6 @@ import { parse } from "@conform-to/zod"; import { ArrowPathIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; import { XCircleIcon } from "@heroicons/react/24/outline"; import { Form } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router"; import { tryCatch } from "@trigger.dev/core"; import { useEffect, useState } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; @@ -48,41 +47,57 @@ import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; import { useUser } from "~/hooks/useUser"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server"; import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder"; import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server"; -export async function loader({ request, params }: LoaderFunctionArgs) { - const userId = await requireUserId(request); +export const loader = dashboardLoader( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "read", resource: { type: "runs" } }, + }, + async ({ request, params, user, ability }) => { + const { organizationSlug, projectParam, envParam } = params; - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } + const presenter = new CreateBulkActionPresenter(); + const data = await presenter.call({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + request, + }); - const presenter = new CreateBulkActionPresenter(); - const data = await presenter.call({ - organizationId: project.organizationId, - projectId: project.id, - environmentId: environment.id, - request, - }); + // Display flag for the inspector's Cancel/Replay controls — the action + // below enforces write:runs independently. + const { canCreateBulkAction } = checkPermissions(ability, { + canCreateBulkAction: { action: "write", resource: { type: "runs" } }, + }); - return typedjson(data); -} + return typedjson({ ...data, canCreateBulkAction }); + } +); export const CreateBulkActionSearchParams = z.object({ mode: BulkActionMode.default("filter"), @@ -112,67 +127,75 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [ ]); export type CreateBulkActionPayload = z.infer; -export async function action({ params, request }: ActionFunctionArgs) { - const userId = await requireUserId(request); +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "runs" } }, + }, + async ({ request, params, user }) => { + const { organizationSlug, projectParam, envParam } = params; - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } + const formData = await request.formData(); + const submission = parse(formData, { schema: CreateBulkActionPayload }); - const formData = await request.formData(); - const submission = parse(formData, { schema: CreateBulkActionPayload }); + if (!submission.value) { + logger.error("Invalid bulk action", { + submission, + formData: Object.fromEntries(formData), + }); + return redirectWithErrorMessage("/", request, "Invalid bulk action"); + } - if (!submission.value) { - logger.error("Invalid bulk action", { - submission, - formData: Object.fromEntries(formData), - }); - return redirectWithErrorMessage("/", request, "Invalid bulk action"); - } + const service = new BulkActionService(); + const [error, result] = await tryCatch( + service.create( + project.organizationId, + project.id, + environment.id, + user.id, + submission.value, + request + ) + ); - const service = new BulkActionService(); - const [error, result] = await tryCatch( - service.create( - project.organizationId, - project.id, - environment.id, - userId, - submission.value, - request - ) - ); + if (error) { + logger.error("Failed to create bulk action", { + error, + }); - if (error) { - logger.error("Failed to create bulk action", { - error, - }); + return redirectWithErrorMessage( + submission.value.failedRedirect, + request, + `Failed to create bulk action: ${error.message}` + ); + } - return redirectWithErrorMessage( - submission.value.failedRedirect, + return redirectWithSuccessMessage( + v3BulkActionPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: result.bulkActionId } + ), request, - `Failed to create bulk action: ${error.message}` + "Bulk action started" ); } - - return redirectWithSuccessMessage( - v3BulkActionPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam }, - { friendlyId: result.bulkActionId } - ), - request, - "Bulk action started" - ); -} +); export function CreateBulkActionInspector({ filters, @@ -209,6 +232,9 @@ export function CreateBulkActionInspector({ const data = fetcher.data != null ? fetcher.data : undefined; + // Permissive while the fetcher is loading; the action enforces write:runs. + const canCreateBulkAction = data?.canCreateBulkAction ?? true; + const impactedCountElement = mode === "selected" ? selectedItems.size : ; @@ -369,7 +395,12 @@ export function CreateBulkActionInspector({ key: "enter", enabledOnInputElements: true, }} - disabled={impactedCountElement === 0 || isDialogOpen} + disabled={impactedCountElement === 0 || isDialogOpen || !canCreateBulkAction} + tooltip={ + canCreateBulkAction + ? undefined + : "You don't have permission to create bulk actions" + } > {action === "replay" ? ( Replay {impactedCountElement} runs… diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index fdc6dfd8242..72a2e4758b5 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -1,26 +1,14 @@ import { useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { - CheckCircleIcon, - ExclamationTriangleIcon, -} from "@heroicons/react/20/solid"; -import { - Form, - useActionData, - useFetcher, - useNavigation, - useLocation, -} from "@remix-run/react"; -import { - type ActionFunctionArgs, - type LoaderFunctionArgs, - json, -} from "@remix-run/server-runtime"; +import { CheckCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; +import { Form, useActionData, useFetcher, useNavigation, useLocation } from "@remix-run/react"; +import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { typedjson, useTypedFetcher } from "remix-typedjson"; import { z } from "zod"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { DialogClose } from "@radix-ui/react-dialog"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { PermissionLink } from "~/components/primitives/PermissionLink"; import { Callout } from "~/components/primitives/Callout"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -39,12 +27,20 @@ import { redirectWithSuccessMessage, redirectWithErrorMessage, } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server"; -import { EnvironmentParamSchema, v3ProjectSettingsIntegrationsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; +import { + EnvironmentParamSchema, + v3ProjectSettingsIntegrationsPath, + vercelAppInstallPath, + vercelResourcePath, +} from "~/utils/pathBuilder"; import { VercelSettingsPresenter, type VercelOnboardingData, @@ -104,7 +100,10 @@ const UpdateVercelConfigFormSchema = z.object({ pullEnvVarsBeforeBuild: envSlugArrayField, discoverEnvVars: envSlugArrayField, vercelStagingEnvironment: z.string().nullable().optional(), - autoPromote: z.string().optional().transform((val) => val !== "false"), + autoPromote: z + .string() + .optional() + .transform((val) => val !== "false"), clearTriggerVersion: z .string() .optional() @@ -123,7 +122,10 @@ const CompleteOnboardingFormSchema = z.object({ discoverEnvVars: envSlugArrayField, syncEnvVarsMapping: z.string().optional(), next: z.string().optional(), - skipRedirect: z.string().optional().transform((val) => val === "true"), + skipRedirect: z + .string() + .optional() + .transform((val) => val === "true"), origin: z.string().optional(), }); @@ -202,6 +204,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const authInvalid = onboardingData?.authInvalid || result.authInvalid || false; const authError = onboardingData?.authError || result.authError; + // Display flag for the connect/disconnect/configure controls — the action + // enforces write:vercel independently. Permissive in OSS. + const sessionAuth = await rbac.authenticateSession(request, { + userId, + organizationId: project.organizationId, + }); + const canManageVercel = sessionAuth.ok + ? sessionAuth.ability.can("write", { type: "vercel" }) + : true; + return typedjson({ ...result, authInvalid, @@ -212,235 +224,264 @@ export async function loader({ request, params }: LoaderFunctionArgs) { environmentSlug: envParam, projectId: project.id, organizationId: project.organizationId, + canManageVercel, }); } -export async function action({ request, params }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response("Not Found", { status: 404 }); - } - - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Response("Not Found", { status: 404 }); - } +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "vercel" } }, + }, + async ({ request, params, user }) => { + const userId = user.id; + const { organizationSlug, projectParam, envParam } = params; + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - const formData = await request.formData(); - const submission = parse(formData, { schema: VercelActionSchema }); + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + const formData = await request.formData(); + const submission = parse(formData, { schema: VercelActionSchema }); - const settingsPath = v3ProjectSettingsIntegrationsPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ); + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } - const vercelService = new VercelIntegrationService(); - const { action: actionType } = submission.value; - - switch (actionType) { - case "update-config": { - const { - atomicBuilds, - pullEnvVarsBeforeBuild, - discoverEnvVars, - vercelStagingEnvironment, - autoPromote, - clearTriggerVersion, - } = submission.value; - - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); - - // Get the previous staging environment before updating - const previousIntegration = await vercelService.getVercelProjectIntegration(project.id); - const previousStagingEnvId = - previousIntegration?.parsedIntegrationData.config?.vercelStagingEnvironment?.environmentId ?? null; - const newStagingEnvId = parsedStagingEnv?.environmentId ?? null; - - const result = await vercelService.updateVercelIntegrationConfig(project.id, { - atomicBuilds, - pullEnvVarsBeforeBuild, - discoverEnvVars, - vercelStagingEnvironment: parsedStagingEnv, - autoPromote, - }); + const settingsPath = v3ProjectSettingsIntegrationsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); - if (result) { - // Sync staging TRIGGER_SECRET_KEY if the custom environment changed - if (previousStagingEnvId !== newStagingEnvId) { - await vercelService.syncStagingKeyForCustomEnvironment( - project.id, - previousStagingEnvId, - newStagingEnvId - ); - } + const vercelService = new VercelIntegrationService(); + const { action: actionType } = submission.value; + + switch (actionType) { + case "update-config": { + const { + atomicBuilds, + pullEnvVarsBeforeBuild, + discoverEnvVars, + vercelStagingEnvironment, + autoPromote, + clearTriggerVersion, + } = submission.value; + + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + + // Get the previous staging environment before updating + const previousIntegration = await vercelService.getVercelProjectIntegration(project.id); + const previousStagingEnvId = + previousIntegration?.parsedIntegrationData.config?.vercelStagingEnvironment + ?.environmentId ?? null; + const newStagingEnvId = parsedStagingEnv?.environmentId ?? null; + + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + atomicBuilds, + pullEnvVarsBeforeBuild, + discoverEnvVars, + vercelStagingEnvironment: parsedStagingEnv, + autoPromote, + }); - // When atomic deployments are being disabled and the user confirmed clearing the pin, - // remove TRIGGER_VERSION from Vercel production so future deploys don't stay pinned. - // If the Vercel API call fails we still consider the settings save itself successful, - // but tell the user so they can clear the env var manually from the Vercel dashboard. - if (clearTriggerVersion && !atomicBuilds?.includes("prod")) { - const cleared = await vercelService.clearTriggerVersionFromVercelProduction(project.id); - if (!cleared) { - return redirectWithErrorMessage( - settingsPath, - request, - "Vercel settings saved, but failed to clear TRIGGER_VERSION on Vercel — please remove it manually from your Vercel project settings." + if (result) { + // Sync staging TRIGGER_SECRET_KEY if the custom environment changed + if (previousStagingEnvId !== newStagingEnvId) { + await vercelService.syncStagingKeyForCustomEnvironment( + project.id, + previousStagingEnvId, + newStagingEnvId ); } + + // When atomic deployments are being disabled and the user confirmed clearing the pin, + // remove TRIGGER_VERSION from Vercel production so future deploys don't stay pinned. + // If the Vercel API call fails we still consider the settings save itself successful, + // but tell the user so they can clear the env var manually from the Vercel dashboard. + if (clearTriggerVersion && !atomicBuilds?.includes("prod")) { + const cleared = await vercelService.clearTriggerVersionFromVercelProduction(project.id); + if (!cleared) { + return redirectWithErrorMessage( + settingsPath, + request, + "Vercel settings saved, but failed to clear TRIGGER_VERSION on Vercel — please remove it manually from your Vercel project settings." + ); + } + } + + return redirectWithSuccessMessage( + settingsPath, + request, + "Vercel settings updated successfully" + ); } - return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully"); + return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings"); } - return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings"); - } + case "disconnect": { + const success = await vercelService.disconnectVercelProject(project.id); - case "disconnect": { - const success = await vercelService.disconnectVercelProject(project.id); + if (success) { + return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected"); + } - if (success) { - return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected"); + return redirectWithErrorMessage( + settingsPath, + request, + "Failed to disconnect Vercel project" + ); } - return redirectWithErrorMessage(settingsPath, request, "Failed to disconnect Vercel project"); - } - - case "complete-onboarding": { - const { - vercelStagingEnvironment, - pullEnvVarsBeforeBuild, - atomicBuilds, - discoverEnvVars, - syncEnvVarsMapping, - next, - skipRedirect, - origin, - } = submission.value; - - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); - const parsedSyncEnvVarsMapping = syncEnvVarsMapping - ? safeJsonParse(syncEnvVarsMapping).unwrapOr(undefined) as SyncEnvVarsMapping | undefined - : undefined; - - const result = await vercelService.completeOnboarding(project.id, { - vercelStagingEnvironment: parsedStagingEnv, - pullEnvVarsBeforeBuild, - atomicBuilds, - discoverEnvVars, - syncEnvVarsMapping: parsedSyncEnvVarsMapping, - origin: origin === "marketplace" ? "marketplace" : "dashboard", - }); + case "complete-onboarding": { + const { + vercelStagingEnvironment, + pullEnvVarsBeforeBuild, + atomicBuilds, + discoverEnvVars, + syncEnvVarsMapping, + next, + skipRedirect, + origin, + } = submission.value; + + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + const parsedSyncEnvVarsMapping = syncEnvVarsMapping + ? (safeJsonParse(syncEnvVarsMapping).unwrapOr(undefined) as + | SyncEnvVarsMapping + | undefined) + : undefined; + + const result = await vercelService.completeOnboarding(project.id, { + vercelStagingEnvironment: parsedStagingEnv, + pullEnvVarsBeforeBuild, + atomicBuilds, + discoverEnvVars, + syncEnvVarsMapping: parsedSyncEnvVarsMapping, + origin: origin === "marketplace" ? "marketplace" : "dashboard", + }); - if (result) { - if (skipRedirect) { - return json({ success: true }); - } + if (result) { + if (skipRedirect) { + return json({ success: true }); + } - if (next) { - const sanitizedNext = sanitizeVercelNextUrl(next); - if (sanitizedNext) { - return json({ success: true, redirectTo: sanitizedNext }); + if (next) { + const sanitizedNext = sanitizeVercelNextUrl(next); + if (sanitizedNext) { + return json({ success: true, redirectTo: sanitizedNext }); + } + logger.warn("Rejected next URL - not same-origin or vercel.com", { next }); } - logger.warn("Rejected next URL - not same-origin or vercel.com", { next }); + + return json({ success: true, redirectTo: settingsPath }); } - return json({ success: true, redirectTo: settingsPath }); + return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); } - return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); - } + case "update-env-mapping": { + const { vercelStagingEnvironment } = submission.value; - case "update-env-mapping": { - const { vercelStagingEnvironment } = submission.value; + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); - const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + vercelStagingEnvironment: parsedStagingEnv, + }); - const result = await vercelService.updateVercelIntegrationConfig(project.id, { - vercelStagingEnvironment: parsedStagingEnv, - }); + if (result) { + // During onboarding there's no previous custom environment — just upsert + await vercelService.syncStagingKeyForCustomEnvironment( + project.id, + null, + parsedStagingEnv?.environmentId ?? null + ); + return json({ success: true }); + } - if (result) { - // During onboarding there's no previous custom environment — just upsert - await vercelService.syncStagingKeyForCustomEnvironment( - project.id, - null, - parsedStagingEnv?.environmentId ?? null + return json( + { success: false, error: "Failed to update environment mapping" }, + { status: 400 } ); - return json({ success: true }); } - return json({ success: false, error: "Failed to update environment mapping" }, { status: 400 }); - } + case "skip-onboarding": { + return redirectWithSuccessMessage( + settingsPath, + request, + "Vercel integration setup skipped" + ); + } - case "skip-onboarding": { - return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped"); - } + case "select-vercel-project": { + const { vercelProjectId, vercelProjectName } = submission.value; + + const selectResult = await fromPromise( + vercelService.selectVercelProject({ + organizationId: project.organizationId, + projectId: project.id, + vercelProjectId, + vercelProjectName, + userId, + }), + (error) => error + ); - case "select-vercel-project": { - const { vercelProjectId, vercelProjectName } = submission.value; - - const selectResult = await fromPromise( - vercelService.selectVercelProject({ - organizationId: project.organizationId, - projectId: project.id, - vercelProjectId, - vercelProjectName, - userId, - }), - (error) => error - ); - - if (selectResult.isErr()) { - logger.error("Failed to select Vercel project", { error: selectResult.error }); - return json({ - error: "Failed to connect Vercel project. Please try again.", - }); - } + if (selectResult.isErr()) { + logger.error("Failed to select Vercel project", { error: selectResult.error }); + return json({ + error: "Failed to connect Vercel project. Please try again.", + }); + } - const { integration, syncResult } = selectResult.value; + const { integration, syncResult } = selectResult.value; + + if (!syncResult.success && syncResult.errors.length > 0) { + logger.warn("Failed to send trigger secrets to Vercel", { + projectId: project.id, + vercelProjectId, + errors: syncResult.errors, + }); + } - if (!syncResult.success && syncResult.errors.length > 0) { - logger.warn("Failed to send trigger secrets to Vercel", { - projectId: project.id, - vercelProjectId, - errors: syncResult.errors, + return json({ + success: true, + integrationId: integration.id, + syncErrors: syncResult.errors, }); } - return json({ - success: true, - integrationId: integration.id, - syncErrors: syncResult.errors, - }); - } - - case "disable-auto-assign": { - const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( - project.id - ); + case "disable-auto-assign": { + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + project.id + ); - if (!orgIntegration) { - return redirectWithErrorMessage(settingsPath, request, "No Vercel integration found"); - } + if (!orgIntegration) { + return redirectWithErrorMessage(settingsPath, request, "No Vercel integration found"); + } - const projectIntegration = await vercelService.getVercelProjectIntegration(project.id); + const projectIntegration = await vercelService.getVercelProjectIntegration(project.id); - if (!projectIntegration) { - return redirectWithErrorMessage(settingsPath, request, "No Vercel project connected"); - } + if (!projectIntegration) { + return redirectWithErrorMessage(settingsPath, request, "No Vercel project connected"); + } - const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); - const disableResult = await VercelIntegrationRepository.getVercelClient(orgIntegration) - .andThen((client) => + const disableResult = await VercelIntegrationRepository.getVercelClient( + orgIntegration + ).andThen((client) => VercelIntegrationRepository.disableAutoAssignCustomDomains( client, projectIntegration.parsedIntegrationData.vercelProjectId, @@ -448,20 +489,31 @@ export async function action({ request, params }: ActionFunctionArgs) { ) ); - if (disableResult.isErr()) { - logger.error("Failed to disable auto-assign custom domains", { error: disableResult.error }); - return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains"); - } + if (disableResult.isErr()) { + logger.error("Failed to disable auto-assign custom domains", { + error: disableResult.error, + }); + return redirectWithErrorMessage( + settingsPath, + request, + "Failed to disable auto-assign custom domains" + ); + } - return redirectWithSuccessMessage(settingsPath, request, "Auto-assign custom domains disabled"); - } + return redirectWithSuccessMessage( + settingsPath, + request, + "Auto-assign custom domains disabled" + ); + } - default: { - submission.value satisfies never; - return redirectBackWithErrorMessage(request, "Failed to process request"); + default: { + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); + } } } -} +); function VercelConnectionPrompt({ organizationSlug, @@ -471,6 +523,7 @@ function VercelConnectionPrompt({ isGitHubConnected, onOpenModal, isLoading, + canManageVercel = true, }: { organizationSlug: string; projectSlug: string; @@ -479,6 +532,7 @@ function VercelConnectionPrompt({ isGitHubConnected: boolean; onOpenModal?: () => void; isLoading?: boolean; + canManageVercel?: boolean; }) { const installPath = vercelAppInstallPath(organizationSlug, projectSlug); @@ -501,11 +555,16 @@ function VercelConnectionPrompt({ + Disconnect Vercel project
Are you sure you want to disconnect{" "} - {connectedProject.vercelProjectName}? - This will stop pulling environment variables and disable atomic deployments. + {connectedProject.vercelProjectName}? This + will stop pulling environment variables and disable atomic deployments. - + {/* Flipped to CLEAR_TRIGGER_VERSION_YES by the clear-pinned-version modal on submit. */} s !== "stg" ); - next.discoverEnvVars = prev.discoverEnvVars.filter( - (s) => s !== "stg" - ); + next.discoverEnvVars = prev.discoverEnvVars.filter((s) => s !== "stg"); } return next; }); @@ -890,37 +974,36 @@ function ConnectedVercelProjectForm({ /> {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} - {autoAssignCustomDomains !== false && - configValues.atomicBuilds.includes("prod") && ( - -
-

- Atomic deployments require the "Auto-assign Custom Domains" setting to be - disabled on your Vercel project. Without this, Vercel will promote - deployments before Trigger.dev is ready. -

- - - - -
-
- )} + {autoAssignCustomDomains !== false && configValues.atomicBuilds.includes("prod") && ( + +
+

+ Atomic deployments require the "Auto-assign Custom Domains" setting to be + disabled on your Vercel project. Without this, Vercel will promote deployments + before Trigger.dev is ready. +

+
+ + + +
+
+ )}
{configForm.error} @@ -934,7 +1017,12 @@ function ConnectedVercelProjectForm({ name="action" value="update-config" variant="secondary/small" - disabled={isConfigLoading || !hasConfigChanges} + disabled={isConfigLoading || !hasConfigChanges || !canManageVercel} + tooltip={ + canManageVercel + ? undefined + : "You don't have permission to manage the Vercel integration" + } LeadingIcon={isConfigLoading ? SpinnerWhite : undefined} onClick={(event) => { if (shouldPromptClearOnSave) { @@ -957,17 +1045,15 @@ function ConnectedVercelProjectForm({ {currentTriggerVersion ? ( Atomic deployments are being turned off. The{" "} - TRIGGER_VERSION env var on - your Vercel production environment is currently set to{" "} + TRIGGER_VERSION env var on your + Vercel production environment is currently set to{" "} {currentTriggerVersion}. ) : ( - Atomic deployments are being turned off. We couldn't reach Vercel to confirm - whether{" "} - TRIGGER_VERSION is currently - set on your Vercel production environment, so please verify in the Vercel - dashboard. + Atomic deployments are being turned off. We couldn't reach Vercel to confirm whether{" "} + TRIGGER_VERSION is currently set + on your Vercel production environment, so please verify in the Vercel dashboard. )} @@ -978,16 +1064,10 @@ function ConnectedVercelProjectForm({ - - @@ -1029,17 +1109,26 @@ function VercelSettingsPanel({ fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug)); setHasFetched(true); } - }, [organizationSlug, projectSlug, environmentSlug, data?.authInvalid, hasError, data, hasFetched]); + }, [ + organizationSlug, + projectSlug, + environmentSlug, + data?.authInvalid, + hasError, + data, + hasFetched, + ]); if (hasError) { return (
- +

Failed to load Vercel settings

-

- There was an error loading the Vercel integration settings. Please refresh the page to try again. +

+ There was an error loading the Vercel integration settings. Please refresh the page to + try again.

@@ -1066,27 +1155,38 @@ function VercelSettingsPanel({ if (data.connectedProject) { return ( <> - {showAuthInvalid && } + {showAuthInvalid && ( + + )} {showGitHubWarning && } - {!showAuthInvalid && ()} + {!showAuthInvalid && ( + + )} ); } return (
- {showAuthInvalid && } + {showAuthInvalid && ( + + )} {!showAuthInvalid && ( <> {data.hasOrgIntegration @@ -1105,8 +1206,8 @@ function VercelSettingsPanel({ {!data.isGitHubConnected && ( - GitHub integration is not connected. Vercel integration cannot sync environment variables and - link deployments without a properly installed GitHub integration. + GitHub integration is not connected. Vercel integration cannot sync environment + variables and link deployments without a properly installed GitHub integration. )} @@ -1115,7 +1216,6 @@ function VercelSettingsPanel({ ); } - import { VercelOnboardingModal } from "~/components/integrations/VercelOnboardingModal"; export { VercelSettingsPanel, VercelOnboardingModal }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 2ba2c761d70..08dcad356c9 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -6,7 +6,6 @@ import { } from "@heroicons/react/20/solid"; import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline"; import { Form, useLocation, useNavigation } from "@remix-run/react"; -import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { uiComponent } from "@team-plain/typescript-sdk"; import { GitHubLightIcon } from "@trigger.dev/companyicons"; import { @@ -40,9 +39,10 @@ import { TextLink } from "~/components/primitives/TextLink"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { logger } from "~/services/logger.server"; import { setPlan } from "~/services/platform.v3.server"; -import { requireUser } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { engine } from "~/v3/runEngine.server"; import { cn } from "~/utils/cn"; import { sendToPlain } from "~/utils/plain.server"; @@ -61,105 +61,110 @@ const schema = z.object({ message: z.string().optional(), }); -export async function action({ request, params }: ActionFunctionArgs) { - if (request.method.toLowerCase() !== "post") { - return new Response("Method not allowed", { status: 405 }); - } - - const { organizationSlug } = Params.parse(params); - const user = await requireUser(request); - const formData = await request.formData(); - const reasons = formData.getAll("reasons"); - const message = formData.get("message"); +export const action = dashboardAction( + { + params: Params, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "manage", resource: { type: "billing" } }, + }, + async ({ request, params, user }) => { + const { organizationSlug } = params; + const formData = await request.formData(); + const reasons = formData.getAll("reasons"); + const message = formData.get("message"); - const form = schema.parse({ - ...Object.fromEntries(formData), - reasons, - message: message || undefined, - }); + const form = schema.parse({ + ...Object.fromEntries(formData), + reasons, + message: message || undefined, + }); - const organization = await prisma.organization.findFirst({ - where: { slug: organizationSlug, members: { some: { userId: user.id } } }, - }); + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId: user.id } } }, + }); - if (!organization) { - throw redirectWithErrorMessage(form.callerPath, request, "Organization not found"); - } + if (!organization) { + throw redirectWithErrorMessage(form.callerPath, request, "Organization not found"); + } - let payload: SetPlanBody; + let payload: SetPlanBody; - switch (form.type) { - case "free": { - try { - if (reasons.length > 0 || (message && message.toString().trim() !== "")) { - await sendToPlain({ - userId: user.id, - email: user.email, - name: user.name ?? "", - title: "Plan cancelation feedback", - components: [ - uiComponent.text({ - text: `${user.name} (${user.email}) just canceled their plan.`, - }), - uiComponent.divider({ spacingSize: "M" }), - ...(reasons.length > 0 - ? [ - uiComponent.spacer({ size: "L" }), - uiComponent.text({ - size: "L", - color: "NORMAL", - text: "Reasons:", - }), - uiComponent.text({ - text: reasons.join(", "), - }), - ] - : []), - ...(message - ? [ - uiComponent.spacer({ size: "L" }), - uiComponent.text({ - size: "L", - color: "NORMAL", - text: "Comment:", - }), - uiComponent.text({ - text: message.toString(), - }), - ] - : []), - ], - }); + switch (form.type) { + case "free": { + try { + if (reasons.length > 0 || (message && message.toString().trim() !== "")) { + await sendToPlain({ + userId: user.id, + email: user.email, + name: user.name ?? "", + title: "Plan cancelation feedback", + components: [ + uiComponent.text({ + text: `${user.name} (${user.email}) just canceled their plan.`, + }), + uiComponent.divider({ spacingSize: "M" }), + ...(reasons.length > 0 + ? [ + uiComponent.spacer({ size: "L" }), + uiComponent.text({ + size: "L", + color: "NORMAL", + text: "Reasons:", + }), + uiComponent.text({ + text: reasons.join(", "), + }), + ] + : []), + ...(message + ? [ + uiComponent.spacer({ size: "L" }), + uiComponent.text({ + size: "L", + color: "NORMAL", + text: "Comment:", + }), + uiComponent.text({ + text: message.toString(), + }), + ] + : []), + ], + }); + } + } catch (e) { + logger.error("Failed to submit to Plain the unsubscribe reason", { error: e }); } - } catch (e) { - logger.error("Failed to submit to Plain the unsubscribe reason", { error: e }); + payload = { + type: "free" as const, + userId: user.id, + }; + break; } - payload = { - type: "free" as const, - userId: user.id, - }; - break; - } - case "paid": { - if (form.planCode === undefined) { - throw redirectWithErrorMessage(form.callerPath, request, "Not a valid plan"); + case "paid": { + if (form.planCode === undefined) { + throw redirectWithErrorMessage(form.callerPath, request, "Not a valid plan"); + } + payload = { + type: "paid" as const, + planCode: form.planCode, + userId: user.id, + }; + break; + } + default: { + throw new Error("Invalid form type"); } - payload = { - type: "paid" as const, - planCode: form.planCode, - userId: user.id, - }; - break; - } - default: { - throw new Error("Invalid form type"); } - } - return await setPlan(organization, request, form.callerPath, payload, { - invalidateBillingCache: engine.invalidateBillingCache.bind(engine), - }); -} + return await setPlan(organization, request, form.callerPath, payload, { + invalidateBillingCache: engine.invalidateBillingCache.bind(engine), + }); + } +); const pricingDefinitions = { usage: { diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts index fa6ee29f3db..4203f385836 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts @@ -1,10 +1,10 @@ import { parse } from "@conform-to/zod"; -import { type ActionFunction, json } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { z } from "zod"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server"; import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server"; @@ -16,104 +16,142 @@ const ParamSchema = z.object({ runParam: z.string(), }); -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { runParam } = ParamSchema.parse(params); - - const formData = await request.formData(); - const submission = parse(formData, { schema: cancelSchema }); +// Resolve the run's organization so the RBAC auth scope can resolve the +// user's role in it. The run may not be in Postgres yet (buffered during a +// burst), so fall back to the buffer entry's org. +async function resolveRunOrganizationId(runParam: string): Promise { + const run = await $replica.taskRun.findFirst({ + where: { friendlyId: runParam }, + select: { project: { select: { organizationId: true } } }, + }); + if (run) { + return run.project.organizationId; + } - if (!submission.value) { - return json(submission); + const buffer = getMollifierBuffer(); + const entry = buffer ? await buffer.getEntry(runParam) : null; + if (entry?.orgId) { + return entry.orgId; } - try { - const taskRun = await prisma.taskRun.findFirst({ - where: { - friendlyId: runParam, - project: { - organization: { - members: { - some: { - userId, + // Replica lag with the buffer entry already drained: the run can exist in + // the primary while both lookups above miss. Fall back to the primary so the + // RBAC scope is never resolved without an org (which would let the role check + // run unscoped under the RBAC plugin). + const primaryRun = await prisma.taskRun.findFirst({ + where: { friendlyId: runParam }, + select: { project: { select: { organizationId: true } } }, + }); + return primaryRun?.project.organizationId ?? null; +} + +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveRunOrganizationId(params.runParam); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "runs" } }, + }, + async ({ request, params, user }) => { + const { runParam } = params; + + const formData = await request.formData(); + const submission = parse(formData, { schema: cancelSchema }); + + if (!submission.value) { + return json(submission); + } + + try { + const taskRun = await prisma.taskRun.findFirst({ + where: { + friendlyId: runParam, + project: { + organization: { + members: { + some: { + userId: user.id, + }, }, }, }, }, - }, - }); + }); - if (taskRun) { - const cancelRunService = new CancelTaskRunService(); - await cancelRunService.call(taskRun); - return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`); - } + if (taskRun) { + const cancelRunService = new CancelTaskRunService(); + await cancelRunService.call(taskRun); + return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`); + } - // PG miss — try the mollifier buffer. The customer can hit cancel - // on a buffered run from the dashboard during the burst window. - // Snapshot a `mark_cancelled` patch; the drainer's - // bifurcation routes the run to `engine.createCancelledRun` on - // next pop. - const buffer = getMollifierBuffer(); - const entry = buffer ? await buffer.getEntry(runParam) : null; - if (!entry) { - submission.error = { runParam: ["Run not found"] }; - return json(submission); - } + // PG miss — try the mollifier buffer. The customer can hit cancel + // on a buffered run from the dashboard during the burst window. + // Snapshot a `mark_cancelled` patch; the drainer's + // bifurcation routes the run to `engine.createCancelledRun` on + // next pop. + const buffer = getMollifierBuffer(); + const entry = buffer ? await buffer.getEntry(runParam) : null; + if (!entry) { + submission.error = { runParam: ["Run not found"] }; + return json(submission); + } - // Dashboard auth: verify the requesting user is a member of the - // buffered run's org. The API path scopes by env id from the - // authenticated request; the dashboard route uses org-membership - // because the URL doesn't carry an envId. - const member = await prisma.orgMember.findFirst({ - where: { userId, organizationId: entry.orgId }, - select: { id: true }, - }); - if (!member) { - submission.error = { runParam: ["Run not found"] }; - return json(submission); - } + // Tenancy: verify the requesting user is a member of the buffered + // run's org. The API path scopes by env id from the authenticated + // request; the dashboard route uses org-membership because the URL + // doesn't carry an envId. + const member = await prisma.orgMember.findFirst({ + where: { userId: user.id, organizationId: entry.orgId }, + select: { id: true }, + }); + if (!member) { + submission.error = { runParam: ["Run not found"] }; + return json(submission); + } - const result = await buffer!.mutateSnapshot(runParam, { - type: "mark_cancelled", - cancelledAt: new Date().toISOString(), - cancelReason: "Canceled by user", - }); - if (result === "applied_to_snapshot") { - return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`); - } - // "not_found" or "busy" — both indicate the drainer raced us between - // the getEntry check above and mutateSnapshot. On "not_found" the - // entry was just popped and the PG row is in flight; on "busy" the - // drainer is mid-materialisation. Either way the customer should - // retry — by then the PG row exists and the regular cancel path at - // the top of this action takes over. - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - "Run is materialising — retry in a moment" - ); - } catch (error) { - if (error instanceof Error) { - logger.error("Failed to cancel run", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, + const result = await buffer!.mutateSnapshot(runParam, { + type: "mark_cancelled", + cancelledAt: new Date().toISOString(), + cancelReason: "Canceled by user", }); + if (result === "applied_to_snapshot") { + return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`); + } + // "not_found" or "busy" — both indicate the drainer raced us between + // the getEntry check above and mutateSnapshot. On "not_found" the + // entry was just popped and the PG row is in flight; on "busy" the + // drainer is mid-materialisation. Either way the customer should + // retry — by then the PG row exists and the regular cancel path at + // the top of this action takes over. return redirectWithErrorMessage( submission.value.redirectUrl, request, - `Failed to cancel run, ${error.message}` - ); - } else { - logger.error("Failed to cancel run", { error }); - return redirectWithErrorMessage( - submission.value.redirectUrl, - request, - `Failed to cancel run, ${JSON.stringify(error)}` + "Run is materialising — retry in a moment" ); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to cancel run", { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + `Failed to cancel run, ${error.message}` + ); + } else { + logger.error("Failed to cancel run", { error }); + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + `Failed to cancel run, ${JSON.stringify(error)}` + ); + } } } -}; +); diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index 03bfdaccc65..5a619887259 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -1,5 +1,5 @@ import { parse } from "@conform-to/zod"; -import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/node"; +import { json, type LoaderFunctionArgs } from "@remix-run/node"; import { type EnvironmentType, prettyPrintPacket } from "@trigger.dev/core/v3"; import { typedjson } from "remix-typedjson"; import { z } from "zod"; @@ -8,6 +8,7 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { sortEnvironments } from "~/utils/environmentSort"; import { v3RunSpanPath } from "~/utils/pathBuilder"; import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server"; @@ -252,169 +253,229 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }); } -export const action: ActionFunction = async ({ request, params }) => { - // Dashboard auth: identical pattern to resources.taskruns.$runParam.cancel.ts. - // The loader above this action already gates with `requireUser`, but - // Remix's action runs independently — without this call any request - // with a valid runParam could submit a replay. The PG findFirst below - // also adds the org-membership filter so a PAT can't replay another - // org's run, and the buffered fallback verifies org membership via - // orgMember.findFirst against the snapshot's orgId. - const user = await requireUser(request); - const userId = user.id; - const { runParam } = ParamSchema.parse(params); - - const formData = await request.formData(); - const submission = parse(formData, { schema: ReplayRunData }); +// Resolve the run's organization so the RBAC auth scope can resolve the +// user's role in it. The run may not be in Postgres yet (buffered during a +// burst), so fall back to the buffer entry's org. +async function resolveRunOrganizationId(runParam: string): Promise { + const run = await $replica.taskRun.findFirst({ + where: { friendlyId: runParam }, + select: { project: { select: { organizationId: true } } }, + }); + if (run) { + return run.project.organizationId; + } - if (!submission.value) { - return json(submission); + const buffer = getMollifierBuffer(); + const entry = buffer ? await buffer.getEntry(runParam) : null; + if (entry?.orgId) { + return entry.orgId; } - try { - const pgRun = await prisma.taskRun.findFirst({ - where: { - friendlyId: runParam, - project: { - organization: { - members: { - some: { - userId, + // Replica lag with the buffer entry already drained: the run can exist in + // the primary while both lookups above miss. Fall back to the primary so the + // RBAC scope is never resolved without an org (which would let the role check + // run unscoped under the RBAC plugin). + const primaryRun = await prisma.taskRun.findFirst({ + where: { friendlyId: runParam }, + select: { project: { select: { organizationId: true } } }, + }); + return primaryRun?.project.organizationId ?? null; +} + +export const action = dashboardAction( + { + params: ParamSchema, + context: async (params) => { + const organizationId = await resolveRunOrganizationId(params.runParam); + return organizationId ? { organizationId } : {}; + }, + authorization: { action: "write", resource: { type: "runs" } }, + }, + // The PG findFirst below keeps the org-membership filter so a user can't + // replay another org's run, and the buffered fallback verifies membership + // via orgMember.findFirst against the snapshot's orgId. + async ({ request, params, user }) => { + const { runParam } = params; + + const formData = await request.formData(); + const submission = parse(formData, { schema: ReplayRunData }); + + if (!submission.value) { + return json(submission); + } + + try { + const pgRun = await prisma.taskRun.findFirst({ + where: { + friendlyId: runParam, + project: { + organization: { + members: { + some: { + userId: user.id, + }, }, }, }, }, - }, - include: { - runtimeEnvironment: { - select: { - slug: true, + include: { + runtimeEnvironment: { + select: { + slug: true, + }, }, - }, - project: { - include: { - organization: true, + project: { + include: { + organization: true, + }, }, }, - }, - }); + }); + + // Mollifier read-fallback: if the original isn't in PG yet, + // synthesise a TaskRun from the buffered snapshot. The B4-extended + // SyntheticRun carries every field ReplayTaskRunService reads. We + // also need projectSlug + orgSlug + envSlug for the redirect path, + // so look those up via the snapshot's runtimeEnvironmentId. + let taskRun: SyntheticReplayTaskRun | null = pgRun ?? null; + if (!taskRun) { + const buffer = getMollifierBuffer(); + const entry = buffer ? await buffer.getEntry(runParam) : null; + if (entry) { + // Same org-membership gate as the PG path above. Without this + // any authenticated user who knows a runId could replay the + // buffered run across orgs. + const member = await prisma.orgMember.findFirst({ + where: { userId: user.id, organizationId: entry.orgId }, + select: { id: true }, + }); + if (!member) { + return redirectWithErrorMessage( + submission.value.failedRedirect, + request, + "Run not found" + ); + } + const synthetic = await findRunByIdWithMollifierFallback({ + runId: runParam, + environmentId: entry.envId, + organizationId: entry.orgId, + }); + if (synthetic) { + const envRow = await prisma.runtimeEnvironment.findFirst({ + // Pin to the buffer entry's org so a malformed entry can't + // resolve an environment in a different org. + where: { id: entry.envId, project: { organizationId: entry.orgId } }, + select: { + slug: true, + project: { select: { slug: true, organization: { select: { slug: true } } } }, + }, + }); + if (envRow) { + taskRun = buildSyntheticReplayTaskRun({ synthetic, envRow }); + } + } + } + } - // Mollifier read-fallback: if the original isn't in PG yet, - // synthesise a TaskRun from the buffered snapshot. The B4-extended - // SyntheticRun carries every field ReplayTaskRunService reads. We - // also need projectSlug + orgSlug + envSlug for the redirect path, - // so look those up via the snapshot's runtimeEnvironmentId. - let taskRun: SyntheticReplayTaskRun | null = pgRun ?? null; - if (!taskRun) { - const buffer = getMollifierBuffer(); - const entry = buffer ? await buffer.getEntry(runParam) : null; - if (entry) { - // Same org-membership gate as the PG path above. Without this - // any authenticated user who knows a runId could replay the - // buffered run across orgs. - const member = await prisma.orgMember.findFirst({ - where: { userId, organizationId: entry.orgId }, + if (!taskRun) { + return redirectWithErrorMessage(submission.value.failedRedirect, request, "Run not found"); + } + + // A replay can target a different environment, but only within the source + // run's own project. The override id is user-supplied and the downstream + // service looks it up without scoping, so confirm it belongs to this + // project before triggering, otherwise a run could be created in another + // tenant's environment. + if (submission.value.environment) { + const overrideEnvironment = await prisma.runtimeEnvironment.findFirst({ + where: { + id: submission.value.environment, + project: { + slug: taskRun.project.slug, + organization: { slug: taskRun.project.organization.slug }, + }, + }, select: { id: true }, }); - if (!member) { + if (!overrideEnvironment) { return redirectWithErrorMessage( submission.value.failedRedirect, request, - "Run not found" + "Environment not found" ); } - const synthetic = await findRunByIdWithMollifierFallback({ - runId: runParam, - environmentId: entry.envId, - organizationId: entry.orgId, - }); - if (synthetic) { - const envRow = await prisma.runtimeEnvironment.findFirst({ - where: { id: entry.envId }, - select: { - slug: true, - project: { select: { slug: true, organization: { select: { slug: true } } } }, - }, - }); - if (envRow) { - taskRun = buildSyntheticReplayTaskRun({ synthetic, envRow }); - } - } } - } - if (!taskRun) { - return redirectWithErrorMessage(submission.value.failedRedirect, request, "Run not found"); - } + const replayRunService = new ReplayTaskRunService(); + const newRun = await replayRunService.call(taskRun, { + environmentId: submission.value.environment, + payload: submission.value.payload, + metadata: submission.value.metadata, + tags: submission.value.tags, + queue: submission.value.queue, + concurrencyKey: submission.value.concurrencyKey, + maxAttempts: submission.value.maxAttempts, + maxDurationSeconds: submission.value.maxDurationSeconds, + machine: submission.value.machine, + region: submission.value.region, + delaySeconds: submission.value.delaySeconds, + idempotencyKey: submission.value.idempotencyKey, + idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds, + ttlSeconds: submission.value.ttlSeconds, + version: submission.value.version, + prioritySeconds: submission.value.prioritySeconds, + triggerSource: "dashboard", + }); - const replayRunService = new ReplayTaskRunService(); - const newRun = await replayRunService.call(taskRun, { - environmentId: submission.value.environment, - payload: submission.value.payload, - metadata: submission.value.metadata, - tags: submission.value.tags, - queue: submission.value.queue, - concurrencyKey: submission.value.concurrencyKey, - maxAttempts: submission.value.maxAttempts, - maxDurationSeconds: submission.value.maxDurationSeconds, - machine: submission.value.machine, - region: submission.value.region, - delaySeconds: submission.value.delaySeconds, - idempotencyKey: submission.value.idempotencyKey, - idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds, - ttlSeconds: submission.value.ttlSeconds, - version: submission.value.version, - prioritySeconds: submission.value.prioritySeconds, - triggerSource: "dashboard", - }); + if (!newRun) { + return redirectWithErrorMessage( + submission.value.failedRedirect, + request, + "Failed to replay run" + ); + } - if (!newRun) { + const runPath = v3RunSpanPath( + { + slug: taskRun.project.organization.slug, + }, + { slug: taskRun.project.slug }, + { slug: taskRun.runtimeEnvironment.slug }, + { friendlyId: newRun.friendlyId }, + { spanId: newRun.spanId } + ); + + logger.debug("Replayed run", { + taskRunId: taskRun.id, + taskRunFriendlyId: taskRun.friendlyId, + newRunId: newRun.id, + newRunFriendlyId: newRun.friendlyId, + runPath, + }); + + return redirectWithSuccessMessage(runPath, request, `Replaying run`); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to replay run", { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message); + } + + logger.error("Failed to replay run", { error }); return redirectWithErrorMessage( submission.value.failedRedirect, request, - "Failed to replay run" + JSON.stringify(error) ); } - - const runPath = v3RunSpanPath( - { - slug: taskRun.project.organization.slug, - }, - { slug: taskRun.project.slug }, - { slug: taskRun.runtimeEnvironment.slug }, - { friendlyId: newRun.friendlyId }, - { spanId: newRun.spanId } - ); - - logger.debug("Replayed run", { - taskRunId: taskRun.id, - taskRunFriendlyId: taskRun.friendlyId, - newRunId: newRun.id, - newRunFriendlyId: newRun.friendlyId, - runPath, - }); - - return redirectWithSuccessMessage(runPath, request, `Replaying run`); - } catch (error) { - if (error instanceof Error) { - logger.error("Failed to replay run", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - }); - return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message); - } - - logger.error("Failed to replay run", { error }); - return redirectWithErrorMessage( - submission.value.failedRedirect, - request, - JSON.stringify(error) - ); } -}; +); async function findTask( environment: { type: EnvironmentType; id: string }, diff --git a/apps/webapp/app/routes/vercel.install.tsx b/apps/webapp/app/routes/vercel.install.tsx index 6a1ca4d7a64..2748cc600b0 100644 --- a/apps/webapp/app/routes/vercel.install.tsx +++ b/apps/webapp/app/routes/vercel.install.tsx @@ -1,9 +1,9 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { logger } from "~/services/logger.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; import { findProjectBySlug } from "~/models/project.server"; @@ -13,61 +13,74 @@ const QuerySchema = z.object({ project_slug: z.string(), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const searchParams = new URL(request.url).searchParams; - const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); - - if (!parsed.success) { - logger.warn("Vercel App installation redirect with invalid params", { - searchParams, - error: parsed.error, - }); - throw redirect("/"); - } - - const { org_slug, project_slug } = parsed.data; - const user = await requireUser(request); - - // Find the organization - const org = await $replica.organization.findFirst({ - where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, - orderBy: { createdAt: "desc" }, - select: { - id: true, +export const loader = dashboardLoader( + { + // The org for the auth scope comes from the `org_slug` query param. + context: async (_params, request) => { + const orgSlug = new URL(request.url).searchParams.get("org_slug"); + if (!orgSlug) return {}; + const organizationId = await resolveOrgIdFromSlug(orgSlug); + return organizationId ? { organizationId } : {}; }, - }); + authorization: { action: "write", resource: { type: "vercel" } }, + // Redirect endpoint (no UI): keep redirecting on denial rather than + // throwing the permission panel. + unauthorizedRedirect: "/", + }, + async ({ request, user }) => { + const searchParams = new URL(request.url).searchParams; + const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); - if (!org) { - throw redirect("/"); - } + if (!parsed.success) { + logger.warn("Vercel App installation redirect with invalid params", { + searchParams, + error: parsed.error, + }); + throw redirect("/"); + } - // Find the project - const project = await findProjectBySlug(org_slug, project_slug, user.id); - if (!project) { - logger.warn("Vercel App installation attempt for non-existent project", { - org_slug, - project_slug, - userId: user.id, + const { org_slug, project_slug } = parsed.data; + + // Find the organization + const org = await $replica.organization.findFirst({ + where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + }, }); - throw redirect("/"); - } - // Use "prod" as the default environment slug for the redirect - // The callback will redirect to the settings page for this environment - const environmentSlug = "prod"; + if (!org) { + throw redirect("/"); + } - // Generate JWT state token - const stateToken = await generateVercelOAuthState({ - organizationId: org.id, - projectId: project.id, - environmentSlug, - organizationSlug: org_slug, - projectSlug: project_slug, - }); + // Find the project + const project = await findProjectBySlug(org_slug, project_slug, user.id); + if (!project) { + logger.warn("Vercel App installation attempt for non-existent project", { + org_slug, + project_slug, + userId: user.id, + }); + throw redirect("/"); + } - // Generate Vercel install URL - const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken); + // Use "prod" as the default environment slug for the redirect + // The callback will redirect to the settings page for this environment + const environmentSlug = "prod"; - return redirect(vercelInstallUrl); -}; + // Generate JWT state token + const stateToken = await generateVercelOAuthState({ + organizationId: org.id, + projectId: project.id, + environmentSlug, + organizationSlug: org_slug, + projectSlug: project_slug, + }); + // Generate Vercel install URL + const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken); + + return redirect(vercelInstallUrl); + } +); diff --git a/apps/webapp/app/services/environmentVariableApiAccess.server.ts b/apps/webapp/app/services/environmentVariableApiAccess.server.ts new file mode 100644 index 00000000000..2ba7b2b91f2 --- /dev/null +++ b/apps/webapp/app/services/environmentVariableApiAccess.server.ts @@ -0,0 +1,75 @@ +import { json } from "@remix-run/server-runtime"; +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { rbac } from "~/services/rbac.server"; + +type EnvironmentScopedResource = "envvars" | "apiKeys"; + +const RESOURCE_LABELS: Record = { + envvars: "environment variables", + apiKeys: "API keys", +}; + +/** + * Env-tier RBAC for environment-scoped API routes (env vars, and the endpoints + * that hand out an environment's secret credentials). + * + * Machine credentials (an environment's secret/public API key) are already + * scoped to a single environment, so they pass through unchanged. A personal + * access token carries a user, so enforce that user's role for the targeted + * environment tier — e.g. a Developer can't read deployed env vars or API keys + * via the API, matching the dashboard restriction. Blocking the credential read + * for deployed tiers is also what stops a restricted role deploying via the CLI + * (deploy needs the environment's secret key). + * + * Returns a `Response` to short-circuit with when access is denied, or + * `undefined` when the request may proceed. + */ +export async function authorizePatEnvironmentAccess({ + request, + authType, + organizationId, + projectId, + envType, + resource, + action, +}: { + request: Request; + authType: "personalAccessToken" | "organizationAccessToken" | "apiKey"; + organizationId: string; + projectId: string; + envType: RuntimeEnvironmentType; + resource: EnvironmentScopedResource; + action: "read" | "write"; +}): Promise { + if (authType !== "personalAccessToken") { + return undefined; + } + + const patAuth = await rbac.authenticatePat(request, { organizationId, projectId }); + if (!patAuth.ok) { + return json({ error: patAuth.error }, { status: patAuth.status }); + } + + if (!patAuth.ability.can(action, { type: resource, envType })) { + return json( + { + error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`, + }, + { status: 403 } + ); + } + + return undefined; +} + +/** Env-tier env var access for the env var API routes. */ +export function authorizeEnvVarApiRequest(opts: { + request: Request; + authType: "personalAccessToken" | "organizationAccessToken" | "apiKey"; + organizationId: string; + projectId: string; + envType: RuntimeEnvironmentType; + action: "read" | "write"; +}): Promise { + return authorizePatEnvironmentAccess({ ...opts, resource: "envvars" }); +} diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts index 656761d852d..01bdb6d9b53 100644 --- a/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts @@ -7,11 +7,8 @@ import { json, redirect } from "@remix-run/server-runtime"; import type { RbacAbility } from "@trigger.dev/rbac"; import { rbac } from "~/services/rbac.server"; import { getUserId } from "~/services/session.server"; -import type { - AuthorizationOption, - DashboardLoaderOptions, - SessionUser, -} from "./dashboardBuilder"; +import { permissionDeniedResponse } from "~/utils/permissionDenied"; +import type { AuthorizationOption, DashboardLoaderOptions, SessionUser } from "./dashboardBuilder"; import { fromZodError } from "zod-validation-error"; import type { z } from "zod"; @@ -33,11 +30,7 @@ function isAuthorized(ability: RbacAbility, authorization: AuthorizationOption): type AuthScope = { organizationId?: string; projectId?: string }; -export async function authenticateAndAuthorize< - TParams, - TSearchParams, - TContext extends AuthScope ->( +export async function authenticateAndAuthorize( request: Request, rawParams: unknown, options: DashboardLoaderOptions @@ -83,9 +76,9 @@ export async function authenticateAndAuthorize< parsedSearchParams = parsed.data; } - const ctx = (options.context - ? await options.context(parsedParams, request) - : ({} as TContext)) as TContext; + const ctx = ( + options.context ? await options.context(parsedParams, request) : ({} as TContext) + ) as TContext; // Resolve userId from the session cookie *here* (the dashboard // request boundary) and feed it into the rbac plugin context. The // plugin no longer takes a `helpers.getSessionUserId` callback — @@ -102,8 +95,31 @@ export async function authenticateAndAuthorize< return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") }; } - if (options.authorization && !isAuthorized(auth.ability, options.authorization)) { - return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") }; + if (options.authorization) { + const isSuperGate = "requireSuper" in options.authorization; + // Every catalogue resource is org- or project-scoped; requireSuper is the + // only global gate. An org/project-scoped check with no resolved scope + // would evaluate an unscoped ability, making the authorization a silent + // no-op for a missing org. Fail closed instead of relying on the ability + // to happen to deny. + const hasScope = Boolean(ctx.organizationId || ctx.projectId); + const denied = isSuperGate + ? !isAuthorized(auth.ability, options.authorization) + : !hasScope || !isAuthorized(auth.ability, options.authorization); + + if (denied) { + // Super-admin gates must not reveal that the route exists, so they + // redirect away rather than render the panel. A redirect is also used by + // routes that opt in via unauthorizedRedirect (credential endpoints with + // no UI). + if (options.unauthorizedRedirect || isSuperGate) { + return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") }; + } + // Role-based denial: throw a permission-denied 403. Both loader and + // action wrappers throw this, so it bubbles to the nearest route + // ErrorBoundary, where RouteErrorDisplay renders the permission panel. + return { ok: false, response: permissionDeniedResponse(options.authorization.message) }; + } } return { diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts index 673f4f78deb..b009c218425 100644 --- a/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts +++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts @@ -35,12 +35,16 @@ export type SessionUser = { }; // `requireSuper: true` enforces ability.canSuper(). Otherwise an explicit -// action + resource pair is checked via ability.can(...). +// action + resource pair is checked via ability.can(...). On failure the +// builder throws a permission-denied 403, rendered as the permission panel by +// the nearest route ErrorBoundary (RouteErrorDisplay), unless +// `unauthorizedRedirect` is set; `message` customizes the panel copy. export type AuthorizationOption = - | { requireSuper: true } + | { requireSuper: true; message?: string } | { action: string; resource: RbacResource | RbacResource[]; + message?: string; }; // Plugin-side scope: whatever the route's `context` returns must include @@ -56,10 +60,7 @@ export type DashboardLoaderOptions, - request: Request - ) => TContext | Promise; + context?: (params: InferZod, request: Request) => TContext | Promise; authorization?: AuthorizationOption; // Where to send unauthenticated requests. Defaults to /login with a // redirectTo back to the original path. @@ -85,9 +86,7 @@ export function dashboardLoader< TReturn extends Response = Response >( options: DashboardLoaderOptions, - handler: ( - args: DashboardLoaderHandlerArgs - ) => Promise + handler: (args: DashboardLoaderHandlerArgs) => Promise ) { return async function loader({ request, params }: LoaderFunctionArgs): Promise { // Server-only — see comment at top. Node caches the module after the @@ -107,8 +106,11 @@ export function dashboardLoader< }; } -export type DashboardActionOptions = - DashboardLoaderOptions; +export type DashboardActionOptions< + TParams, + TSearchParams, + TContext extends AuthScope +> = DashboardLoaderOptions; export type DashboardActionHandlerArgs = DashboardLoaderHandlerArgs; @@ -120,9 +122,7 @@ export function dashboardAction< TReturn extends Response = Response >( options: DashboardActionOptions, - handler: ( - args: DashboardActionHandlerArgs - ) => Promise + handler: (args: DashboardActionHandlerArgs) => Promise ) { return async function action({ request, params }: ActionFunctionArgs): Promise { const { authenticateAndAuthorize } = await import("./dashboardBuilder.server"); diff --git a/apps/webapp/app/services/routeBuilders/permissions.server.ts b/apps/webapp/app/services/routeBuilders/permissions.server.ts new file mode 100644 index 00000000000..8d574abcea9 --- /dev/null +++ b/apps/webapp/app/services/routeBuilders/permissions.server.ts @@ -0,0 +1,33 @@ +import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; + +/** + * A single permission check, mirroring the `authorization` option the + * dashboard/api route builders accept: either a super-user check or an + * action + resource(s) pair. + */ +export type PermissionCheck = + | { requireSuper: true } + | { action: string; resource: RbacResource | RbacResource[] }; + +/** + * Evaluate a set of permission checks against an already-resolved `ability` + * and return a plain boolean map for the client to gate UI on. + * + * The matching lives entirely in the injected ability — permissive by + * default, and fully enforced when an RBAC plugin is installed — so this only + * calls `can`/`canSuper` and no permission-model logic lives here. The + * returned booleans are display-only: the route builder's `authorization` + * block is the real security boundary. + */ +export function checkPermissions( + ability: RbacAbility, + checks: Record +): Record { + const result = {} as Record; + for (const key in checks) { + const check = checks[key]; + result[key] = + "requireSuper" in check ? ability.canSuper() : ability.can(check.action, check.resource); + } + return result; +} diff --git a/apps/webapp/app/utils/permissionDenied.ts b/apps/webapp/app/utils/permissionDenied.ts new file mode 100644 index 00000000000..b44b6941b1f --- /dev/null +++ b/apps/webapp/app/utils/permissionDenied.ts @@ -0,0 +1,39 @@ +import { json } from "@remix-run/server-runtime"; + +// Marker on the thrown 403 body so the error boundary can tell a +// permission denial apart from any other route error. +export const PERMISSION_DENIED_MARKER = "rbac-permission-denied"; + +const DEFAULT_PERMISSION_DENIED_MESSAGE = "You don't have permission to access this page."; + +/** Build the 403 response thrown when the current role lacks access. */ +export function permissionDeniedResponse(message?: string): Response { + return json( + { [PERMISSION_DENIED_MARKER]: true, message: message ?? DEFAULT_PERMISSION_DENIED_MESSAGE }, + { status: 403 } + ); +} + +/** + * Throw from a loader/action when the current role lacks access. The thrown + * 403 bubbles to the nearest route ErrorBoundary, where RouteErrorDisplay + * renders the permission panel. `dashboardLoader`/`dashboardAction` do this + * automatically when an `authorization` block fails; call this directly for + * checks the block can't express (e.g. "any of these permissions"). + */ +export function throwPermissionDenied(message?: string): never { + throw permissionDeniedResponse(message); +} + +/** Returns the message when `data` is a permission-denied payload, else null. */ +export function permissionDeniedMessage(data: unknown): string | null { + if ( + data && + typeof data === "object" && + (data as Record)[PERMISSION_DENIED_MARKER] + ) { + const message = (data as Record).message; + return typeof message === "string" ? message : DEFAULT_PERMISSION_DENIED_MESSAGE; + } + return null; +} diff --git a/apps/webapp/test/checkPermissions.test.ts b/apps/webapp/test/checkPermissions.test.ts new file mode 100644 index 00000000000..84a18d63bef --- /dev/null +++ b/apps/webapp/test/checkPermissions.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import type { RbacAbility } from "@trigger.dev/rbac"; +import { checkPermissions } from "~/services/routeBuilders/permissions.server"; + +const permissive: RbacAbility = { can: () => true, canSuper: () => false }; +const denyAll: RbacAbility = { can: () => false, canSuper: () => false }; + +describe("checkPermissions", () => { + it("returns true for every check under a permissive ability (OSS path)", () => { + const result = checkPermissions(permissive, { + canCancelRun: { action: "write", resource: { type: "runs" } }, + canManageMembers: { action: "manage", resource: { type: "members" } }, + }); + + expect(result).toEqual({ canCancelRun: true, canManageMembers: true }); + }); + + it("returns false for every check under a deny-all ability", () => { + const result = checkPermissions(denyAll, { + canCancelRun: { action: "write", resource: { type: "runs" } }, + }); + + expect(result).toEqual({ canCancelRun: false }); + }); + + it("evaluates each check independently against can()", () => { + const ability: RbacAbility = { + can: (action, resource) => { + const r = Array.isArray(resource) ? resource[0] : resource; + return action === "read" || r.type === "tasks"; + }, + canSuper: () => false, + }; + + const result = checkPermissions(ability, { + readRuns: { action: "read", resource: { type: "runs" } }, + writeRuns: { action: "write", resource: { type: "runs" } }, + writeTasks: { action: "write", resource: { type: "tasks" } }, + }); + + expect(result).toEqual({ readRuns: true, writeRuns: false, writeTasks: true }); + }); + + it("supports requireSuper checks via canSuper()", () => { + const admin: RbacAbility = { can: () => false, canSuper: () => true }; + + expect(checkPermissions(admin, { adminOnly: { requireSuper: true } })).toEqual({ + adminOnly: true, + }); + expect(checkPermissions(denyAll, { adminOnly: { requireSuper: true } })).toEqual({ + adminOnly: false, + }); + }); + + it("passes resource arrays straight through to can()", () => { + const seen: unknown[] = []; + const ability: RbacAbility = { + can: (_action, resource) => { + seen.push(resource); + return true; + }, + canSuper: () => false, + }; + + checkPermissions(ability, { + x: { action: "read", resource: [{ type: "runs" }, { type: "tasks" }] }, + }); + + expect(seen[0]).toEqual([{ type: "runs" }, { type: "tasks" }]); + }); +});