Skip to content

Commit 56823c3

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(webapp): use branch permissions for branch management
Mono-RevId: 6620c9d8e5ba543eeb8ef18cc82e944e3b3fb365
1 parent 51788be commit 56823c3

9 files changed

Lines changed: 99 additions & 19 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Creating and archiving Development and Preview branches now requires the branch management permission, which the Developer role has by default.

‎apps/webapp/app/components/BlankStatePanels.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,11 +478,13 @@ export function BranchesNoBranches({
478478
limits,
479479
canUpgrade,
480480
showSelfServe,
481+
canCreateBranches,
481482
}: {
482483
env: BranchableEnvironmentToken;
483484
limits: { used: number; limit: number };
484485
canUpgrade: boolean;
485486
showSelfServe: boolean;
487+
canCreateBranches: boolean;
486488
}) {
487489
const organization = useOrganization();
488490

@@ -534,6 +536,10 @@ export function BranchesNoBranches({
534536
variant="primary/small"
535537
LeadingIcon={PlusIcon}
536538
leadingIconClassName="text-white"
539+
disabled={!canCreateBranches}
540+
tooltip={
541+
canCreateBranches ? undefined : "You don't have permission to create branches."
542+
}
537543
>
538544
New branch
539545
</Button>

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ export const loader = dashboardLoader(
122122

123123
return typedjson({
124124
...result,
125-
canArchiveBranches: ability.can("write", { type: "deployments", envType: "PREVIEW" }),
125+
canManageBranches:
126+
ability.can("write", { type: "branches", envType: "PREVIEW" }) ||
127+
ability.can("write", { type: "deployments", envType: "PREVIEW" }),
126128
});
127129
} catch (error) {
128130
logger.error("Error loading preview branches page", { error });
@@ -217,7 +219,7 @@ export default function Page() {
217219
totalPages,
218220
hasBranches,
219221
canPurchaseBranches,
220-
canArchiveBranches,
222+
canManageBranches,
221223
extraBranches,
222224
branchPricing,
223225
maxBranchQuota,
@@ -301,6 +303,10 @@ export default function Page() {
301303
leadingIconClassName="text-white"
302304
fullWidth
303305
textAlignLeft
306+
disabled={!canManageBranches}
307+
tooltip={
308+
canManageBranches ? undefined : "You don't have permission to create branches."
309+
}
304310
>
305311
New branch…
306312
</Button>
@@ -319,6 +325,7 @@ export default function Page() {
319325
limits={limits}
320326
canUpgrade={canUpgrade ?? false}
321327
showSelfServe={showSelfServe}
328+
canCreateBranches={canManageBranches}
322329
/>
323330
</MainCenteredContainer>
324331
) : (
@@ -415,7 +422,7 @@ export default function Page() {
415422
{!branch.archivedAt ? (
416423
<ArchiveButton
417424
environment={branch}
418-
canArchive={canArchiveBranches}
425+
canArchive={canManageBranches}
419426
/>
420427
) : null}
421428
</>

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev-branches/route.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ export const loader = dashboardLoader(
7676

7777
return typedjson({
7878
...result,
79-
canArchiveBranches: ability.can("write", { type: "deployments", envType: "DEVELOPMENT" }),
79+
canManageBranches:
80+
ability.can("write", { type: "branches", envType: "DEVELOPMENT" }) ||
81+
ability.can("write", { type: "deployments", envType: "DEVELOPMENT" }),
8082
});
8183
} catch (error) {
8284
logger.error("Error loading dev branches page", { error });
@@ -93,7 +95,7 @@ export const handle: Handle = {
9395
};
9496

9597
export default function Page() {
96-
const { branches, limits, currentPage, totalPages, canArchiveBranches } =
98+
const { branches, limits, currentPage, totalPages, canManageBranches } =
9799
useTypedLoaderData<typeof loader>();
98100
useAutoRevalidate({ interval: 5000 });
99101

@@ -144,6 +146,10 @@ export default function Page() {
144146
leadingIconClassName="text-white"
145147
fullWidth
146148
textAlignLeft
149+
disabled={!canManageBranches}
150+
tooltip={
151+
canManageBranches ? undefined : "You don't have permission to create branches."
152+
}
147153
>
148154
New branch…
149155
</Button>
@@ -249,7 +255,7 @@ export default function Page() {
249255
{!branch.archivedAt ? (
250256
<ArchiveButton
251257
environment={branch}
252-
canArchive={canArchiveBranches}
258+
canArchive={canManageBranches}
253259
// The root dev env (no parent) is the default
254260
// branch and can't be archived — matches the
255261
// guard in ArchiveBranchService.

‎apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
157157
organizationId: environment.organizationId,
158158
projectId: environment.projectId,
159159
envType: environment.type,
160-
resource: "deployments",
160+
resource: ["branches", "deployments"],
161161
action: "write",
162162
});
163163
if (denied) return denied;

‎apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { DEFAULT_DEV_BRANCH, isDefaultDevBranch } from "@trigger.dev/core/v3/uti
44
import { z } from "zod";
55
import { prisma } from "~/db.server";
66
import { authenticateRequestWithScopedApiKey } from "~/services/apiAuth.server";
7+
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
78
import { logger } from "~/services/logger.server";
89
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
910
import { UpsertBranchService } from "~/services/upsertBranch.server";
11+
import { toBranchableEnvironmentType } from "~/utils/branchableEnvironment";
1012

1113
const ParamsSchema = z.object({
1214
projectRef: z.string(),
@@ -48,16 +50,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
4850

4951
const { projectRef } = parsedParams.data;
5052

51-
let project: { id: string } | null | undefined;
53+
let project: { id: string; organizationId: string } | null | undefined;
5254
if (authenticationResult.type === "apiKey") {
5355
project =
5456
apiKeyEnvironment?.project.externalRef === projectRef
55-
? { id: apiKeyEnvironment.project.id }
57+
? { id: apiKeyEnvironment.project.id, organizationId: apiKeyEnvironment.organizationId }
5658
: undefined;
5759
} else {
5860
project = await prisma.project.findFirst({
5961
select: {
6062
id: true,
63+
organizationId: true,
6164
},
6265
where: {
6366
externalRef: projectRef,
@@ -121,6 +124,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
121124
);
122125
}
123126

127+
if (authenticationResult.type !== "apiKey") {
128+
const denied = await authorizePatEnvironmentAccess({
129+
request,
130+
authType: authenticationResult.type,
131+
organizationId: project.organizationId,
132+
projectId: project.id,
133+
envType: toBranchableEnvironmentType(env),
134+
resource: ["branches", "deployments"],
135+
action: "write",
136+
});
137+
if (denied) return denied;
138+
}
139+
124140
let orgFilter:
125141
| { type: "userMembership"; userId: string }
126142
| { type: "orgId"; organizationId: string };

‎apps/webapp/app/routes/resources.branches.archive.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ export async function action({ request }: ActionFunctionArgs) {
5555
organizationId: environment.organizationId,
5656
projectId: environment.projectId,
5757
});
58-
if (!auth.ok || !auth.ability.can("write", { type: "deployments", envType: environment.type })) {
58+
const canArchive =
59+
auth.ok &&
60+
(auth.ability.can("write", { type: "branches", envType: environment.type }) ||
61+
auth.ability.can("write", { type: "deployments", envType: environment.type }));
62+
if (!canArchive) {
5963
return redirectWithErrorMessage(
6064
redirectPath,
6165
request,

‎apps/webapp/app/routes/resources.branches.create.tsx‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useFetcher, useLocation, useSearchParams } from "@remix-run/react";
55
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
66
import { useEffect, useState } from "react";
77
import { InlineCode } from "~/components/code/InlineCode";
8+
import { $replica } from "~/db.server";
89
import { Button } from "~/components/primitives/Buttons";
910
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
1011
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -16,9 +17,13 @@ import { InputGroup } from "~/components/primitives/InputGroup";
1617
import { Label } from "~/components/primitives/Label";
1718
import { useProject } from "~/hooks/useProject";
1819
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
20+
import { rbac } from "~/services/rbac.server";
1921
import { requireUserId } from "~/services/session.server";
2022
import { UpsertBranchService } from "~/services/upsertBranch.server";
21-
import { type BranchableEnvironmentToken } from "~/utils/branchableEnvironment";
23+
import {
24+
type BranchableEnvironmentToken,
25+
toBranchableEnvironmentType,
26+
} from "~/utils/branchableEnvironment";
2227
import { CreateBranchFormSchema } from "~/utils/branches";
2328
import { branchesDevPath, branchesPath } from "~/utils/pathBuilder";
2429

@@ -32,6 +37,34 @@ export async function action({ request }: ActionFunctionArgs) {
3237
return redirectWithErrorMessage("/", request, "Invalid form data");
3338
}
3439

40+
const project = await $replica.project.findFirst({
41+
where: {
42+
id: submission.value.projectId,
43+
organization: { members: { some: { userId } } },
44+
},
45+
select: { id: true, organizationId: true },
46+
});
47+
if (!project) {
48+
return json(submission.reply({ formErrors: ["Project not found"] }), { status: 404 });
49+
}
50+
51+
const environmentType = toBranchableEnvironmentType(submission.value.env);
52+
const auth = await rbac.authenticateSession(request, {
53+
userId,
54+
organizationId: project.organizationId,
55+
projectId: project.id,
56+
});
57+
const canCreate =
58+
auth.ok &&
59+
(auth.ability.can("write", { type: "branches", envType: environmentType }) ||
60+
auth.ability.can("write", { type: "deployments", envType: environmentType }));
61+
if (!canCreate) {
62+
return json(
63+
submission.reply({ formErrors: ["You don't have permission to create branches."] }),
64+
{ status: 403 }
65+
);
66+
}
67+
3568
const upsertBranchService = new UpsertBranchService();
3669
const result = await upsertBranchService.call(
3770
{ type: "userMembership", userId },

‎apps/webapp/app/services/environmentVariableApiAccess.server.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from "~/services/apiAuth.server";
1212
import { rbac } from "~/services/rbac.server";
1313

14-
type EnvironmentScopedResource = "envvars" | "apiKeys" | "deployments";
14+
type EnvironmentScopedResource = "envvars" | "apiKeys" | "deployments" | "branches";
1515

1616
type EnvironmentScopedAuthentication =
1717
| { ok: true; authentication: AuthenticationResult }
@@ -112,11 +112,11 @@ const RESOURCE_LABELS: Record<EnvironmentScopedResource, string> = {
112112
envvars: "environment variables",
113113
apiKeys: "API keys",
114114
deployments: "deployments",
115+
branches: "branches",
115116
};
116117

117118
/**
118-
* Env-tier RBAC for environment-scoped API routes (env vars, and the endpoints
119-
* that hand out an environment's secret credentials).
119+
* Env-tier RBAC for environment-scoped API routes.
120120
*
121121
* Machine credentials (an environment's API key) are authorized by the
122122
* ability returned by the RBAC bearer controller. A personal
@@ -145,11 +145,13 @@ export async function authorizePatEnvironmentAccess({
145145
organizationId: string;
146146
projectId: string;
147147
envType: RuntimeEnvironmentType;
148-
resource: EnvironmentScopedResource;
148+
resource: EnvironmentScopedResource | EnvironmentScopedResource[];
149149
action: "read" | "write";
150150
// Controller ability for API-key credentials. Absent for PAT/OAT callers.
151151
ability?: RbacAbility;
152152
}): Promise<Response | undefined> {
153+
const resources = Array.isArray(resource) ? resource : [resource];
154+
const resourceLabel = RESOURCE_LABELS[resources[0] ?? "branches"];
153155
const bearer = request.headers
154156
.get("Authorization")
155157
?.replace(/^Bearer /, "")
@@ -159,12 +161,12 @@ export async function authorizePatEnvironmentAccess({
159161
// Machine API keys are authorized by their controller ability. Root keys and
160162
// ungranted additional keys are permissive; granted keys are restricted.
161163
if (authType === "apiKey") {
162-
if (ability?.can(action, { type: resource })) {
164+
if (resources.some((candidate) => ability?.can(action, { type: candidate }))) {
163165
return undefined;
164166
}
165167
return json(
166168
{
167-
error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`,
169+
error: `You don't have permission to access this environment's ${resourceLabel}.`,
168170
},
169171
{ status: 403 }
170172
);
@@ -183,10 +185,10 @@ export async function authorizePatEnvironmentAccess({
183185
return json({ error: userAuth.error }, { status: userAuth.status });
184186
}
185187

186-
if (!userAuth.ability.can(action, { type: resource, envType })) {
188+
if (!resources.some((candidate) => userAuth.ability.can(action, { type: candidate, envType }))) {
187189
return json(
188190
{
189-
error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`,
191+
error: `You don't have permission to access this environment's ${resourceLabel}.`,
190192
},
191193
{ status: 403 }
192194
);

0 commit comments

Comments
 (0)