Skip to content

Commit a60eb70

Browse files
committed
fix(webapp): enforce write:deployments on rollback/promote/cancel routes + UI
Migrate the three deployment resource-route actions to dashboardAction with a write:deployments authorization block, resolving the org for the auth scope from the project. Surface canWriteDeployments from the deployments loader and gate the Rollback/Promote/Cancel row-menu items (disable + tooltip when denied). Tenancy/membership queries unchanged; permissive in OSS.
1 parent 5c3ebf3 commit a60eb70

4 files changed

Lines changed: 418 additions & 286 deletions

File tree

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx

Lines changed: 144 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ import {
7171
DeploymentListPresenter,
7272
} from "~/presenters/v3/DeploymentListPresenter.server";
7373
import { requireUserId } from "~/services/session.server";
74+
import { $replica } from "~/db.server";
75+
import { rbac } from "~/services/rbac.server";
76+
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
7477
import { titleCase } from "~/utils";
7578
import { cn } from "~/utils/cn";
7679
import {
@@ -98,6 +101,11 @@ const SearchParams = z.object({
98101
version: z.string().optional(),
99102
});
100103

104+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
105+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
106+
return org?.id ?? null;
107+
}
108+
101109
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
102110
const userId = await requireUserId(request);
103111
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
@@ -141,7 +149,25 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
141149

142150
const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS;
143151

144-
return typedjson({ ...result, selectedDeployment, autoReloadPollIntervalMs });
152+
// Display flag for the rollback/promote/cancel controls — the action
153+
// routes enforce write:deployments independently. Permissive in OSS.
154+
const orgId = await resolveOrgIdFromSlug(organizationSlug);
155+
const deploymentAuth = orgId
156+
? await rbac.authenticateSession(request, { userId, organizationId: orgId })
157+
: null;
158+
const canWriteDeployments =
159+
deploymentAuth && deploymentAuth.ok
160+
? checkPermissions(deploymentAuth.ability, {
161+
canWriteDeployments: { action: "write", resource: { type: "deployments" } },
162+
}).canWriteDeployments
163+
: true;
164+
165+
return typedjson({
166+
...result,
167+
selectedDeployment,
168+
autoReloadPollIntervalMs,
169+
canWriteDeployments,
170+
});
145171
} catch (error) {
146172
console.error(error);
147173
throw new Response(undefined, {
@@ -164,6 +190,7 @@ export default function Page() {
164190
environmentGitHubBranch,
165191
autoReloadPollIntervalMs,
166192
hasVercelIntegration,
193+
canWriteDeployments,
167194
} = useTypedLoaderData<typeof loader>();
168195
const hasDeployments = totalPages > 0;
169196

@@ -257,7 +284,12 @@ export default function Page() {
257284
<TableRow key={deployment.id} className="group" isSelected={isSelected}>
258285
<TableCell to={path} isTabbableCell isSelected={isSelected}>
259286
<div className="flex items-center gap-2">
260-
<Paragraph variant="extra-small" className="group-hover/table-row:text-text-bright">{deployment.shortCode}</Paragraph>
287+
<Paragraph
288+
variant="extra-small"
289+
className="group-hover/table-row:text-text-bright"
290+
>
291+
{deployment.shortCode}
292+
</Paragraph>
261293
{deployment.label && (
262294
<Badge variant="extra-small">{titleCase(deployment.label)}</Badge>
263295
)}
@@ -333,6 +365,7 @@ export default function Page() {
333365
path={path}
334366
isSelected={isSelected}
335367
currentDeployment={currentDeployment}
368+
canWriteDeployments={canWriteDeployments}
336369
/>
337370
</TableRow>
338371
);
@@ -419,8 +452,14 @@ export default function Page() {
419452
export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
420453
return (
421454
<div className="flex items-center gap-1">
422-
<UserAvatar avatarUrl={avatarUrl} name={name} className="h-4 w-4 group-hover/table-row:text-text-bright" />
423-
<Paragraph variant="extra-small" className="group-hover/table-row:text-text-bright">{name}</Paragraph>
455+
<UserAvatar
456+
avatarUrl={avatarUrl}
457+
name={name}
458+
className="h-4 w-4 group-hover/table-row:text-text-bright"
459+
/>
460+
<Paragraph variant="extra-small" className="group-hover/table-row:text-text-bright">
461+
{name}
462+
</Paragraph>
424463
</div>
425464
);
426465
}
@@ -430,11 +469,13 @@ function DeploymentActionsCell({
430469
path,
431470
isSelected,
432471
currentDeployment,
472+
canWriteDeployments,
433473
}: {
434474
deployment: DeploymentListItem;
435475
path: string;
436476
isSelected: boolean;
437477
currentDeployment?: DeploymentListItem;
478+
canWriteDeployments: boolean;
438479
}) {
439480
const location = useLocation();
440481
const project = useProject();
@@ -463,66 +504,105 @@ function DeploymentActionsCell({
463504
isSelected={isSelected}
464505
popoverContent={
465506
<>
466-
{canBeRolledBack && (
467-
<Dialog>
468-
<DialogTrigger asChild>
469-
<Button
470-
variant="small-menu-item"
471-
LeadingIcon={ArrowUturnLeftIcon}
472-
leadingIconClassName="text-blue-500"
473-
fullWidth
474-
textAlignLeft
475-
>
476-
Rollback
477-
</Button>
478-
</DialogTrigger>
479-
<RollbackDeploymentDialog
480-
projectId={project.id}
481-
deploymentShortCode={deployment.shortCode}
482-
redirectPath={`${location.pathname}${location.search}`}
483-
/>
484-
</Dialog>
485-
)}
486-
{canBePromoted && (
487-
<Dialog>
488-
<DialogTrigger asChild>
489-
<Button
490-
variant="small-menu-item"
491-
LeadingIcon={PromoteIcon}
492-
leadingIconClassName="text-blue-500"
493-
fullWidth
494-
textAlignLeft
495-
>
496-
Promote
497-
</Button>
498-
</DialogTrigger>
499-
<PromoteDeploymentDialog
500-
projectId={project.id}
501-
deploymentShortCode={deployment.shortCode}
502-
redirectPath={`${location.pathname}${location.search}`}
503-
/>
504-
</Dialog>
505-
)}
506-
{canBeCanceled && (
507-
<Dialog>
508-
<DialogTrigger asChild>
509-
<Button
510-
variant="small-menu-item"
511-
LeadingIcon={NoSymbolIcon}
512-
leadingIconClassName="text-error"
513-
fullWidth
514-
textAlignLeft
515-
>
516-
Cancel
517-
</Button>
518-
</DialogTrigger>
519-
<CancelDeploymentDialog
520-
projectId={project.id}
521-
deploymentShortCode={deployment.shortCode}
522-
redirectPath={`${location.pathname}${location.search}`}
523-
/>
524-
</Dialog>
525-
)}
507+
{canBeRolledBack &&
508+
(canWriteDeployments ? (
509+
<Dialog>
510+
<DialogTrigger asChild>
511+
<Button
512+
variant="small-menu-item"
513+
LeadingIcon={ArrowUturnLeftIcon}
514+
leadingIconClassName="text-blue-500"
515+
fullWidth
516+
textAlignLeft
517+
>
518+
Rollback
519+
</Button>
520+
</DialogTrigger>
521+
<RollbackDeploymentDialog
522+
projectId={project.id}
523+
deploymentShortCode={deployment.shortCode}
524+
redirectPath={`${location.pathname}${location.search}`}
525+
/>
526+
</Dialog>
527+
) : (
528+
<Button
529+
variant="small-menu-item"
530+
LeadingIcon={ArrowUturnLeftIcon}
531+
leadingIconClassName="text-blue-500"
532+
fullWidth
533+
textAlignLeft
534+
disabled
535+
tooltip="You don't have permission to roll back deployments"
536+
>
537+
Rollback
538+
</Button>
539+
))}
540+
{canBePromoted &&
541+
(canWriteDeployments ? (
542+
<Dialog>
543+
<DialogTrigger asChild>
544+
<Button
545+
variant="small-menu-item"
546+
LeadingIcon={PromoteIcon}
547+
leadingIconClassName="text-blue-500"
548+
fullWidth
549+
textAlignLeft
550+
>
551+
Promote
552+
</Button>
553+
</DialogTrigger>
554+
<PromoteDeploymentDialog
555+
projectId={project.id}
556+
deploymentShortCode={deployment.shortCode}
557+
redirectPath={`${location.pathname}${location.search}`}
558+
/>
559+
</Dialog>
560+
) : (
561+
<Button
562+
variant="small-menu-item"
563+
LeadingIcon={PromoteIcon}
564+
leadingIconClassName="text-blue-500"
565+
fullWidth
566+
textAlignLeft
567+
disabled
568+
tooltip="You don't have permission to promote deployments"
569+
>
570+
Promote
571+
</Button>
572+
))}
573+
{canBeCanceled &&
574+
(canWriteDeployments ? (
575+
<Dialog>
576+
<DialogTrigger asChild>
577+
<Button
578+
variant="small-menu-item"
579+
LeadingIcon={NoSymbolIcon}
580+
leadingIconClassName="text-error"
581+
fullWidth
582+
textAlignLeft
583+
>
584+
Cancel
585+
</Button>
586+
</DialogTrigger>
587+
<CancelDeploymentDialog
588+
projectId={project.id}
589+
deploymentShortCode={deployment.shortCode}
590+
redirectPath={`${location.pathname}${location.search}`}
591+
/>
592+
</Dialog>
593+
) : (
594+
<Button
595+
variant="small-menu-item"
596+
LeadingIcon={NoSymbolIcon}
597+
leadingIconClassName="text-error"
598+
fullWidth
599+
textAlignLeft
600+
disabled
601+
tooltip="You don't have permission to cancel deployments"
602+
>
603+
Cancel
604+
</Button>
605+
))}
526606
</>
527607
}
528608
/>

0 commit comments

Comments
 (0)