Skip to content

Commit 98408ef

Browse files
committed
feat(webapp): enforce env var permissions on the dashboard
The environment variables list now withholds values for environments the current role can't read and shows a permission-denied state in their place. The create dialog disables the environment targets the role can't write (with a tooltip) and its action rejects those targets server-side. The permission-denied states use the no-entry icon.
1 parent c287f6d commit 98408ef

3 files changed

Lines changed: 363 additions & 222 deletions

File tree

  • apps/webapp/app
    • components
    • routes
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables

apps/webapp/app/components/PermissionDenied.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { organizationRolesPath } from "~/utils/pathBuilder";
22
import { LinkButton } from "./primitives/Buttons";
33
import { InfoPanel } from "./primitives/InfoPanel";
4-
import { LockClosedIcon } from "@heroicons/react/20/solid";
4+
import { NoSymbolIcon } from "@heroicons/react/20/solid";
55
import { useOrganization } from "~/hooks/useOrganizations";
66
import React from "react";
77

@@ -10,7 +10,7 @@ export function PermissionDenied({ message }: { message: React.ReactNode }) {
1010

1111
return (
1212
<InfoPanel
13-
icon={LockClosedIcon}
13+
icon={NoSymbolIcon}
1414
iconClassName="text-text-dimmed"
1515
title="Permission denied"
1616
accessory={

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx

Lines changed: 137 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,21 @@ import {
77
useForm,
88
} from "@conform-to/react";
99
import { parse } from "@conform-to/zod";
10-
import { LockClosedIcon, LockOpenIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
10+
import {
11+
LockClosedIcon,
12+
LockOpenIcon,
13+
NoSymbolIcon,
14+
PlusIcon,
15+
XMarkIcon,
16+
} from "@heroicons/react/20/solid";
1117
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
12-
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
18+
import { json } from "@remix-run/server-runtime";
1319
import dotenv from "dotenv";
1420
import { type RefObject, useCallback, useRef, useState } from "react";
1521
import { redirect } from "remix-typedjson";
1622
import invariant from "tiny-invariant";
1723
import { z } from "zod";
18-
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
24+
import { EnvironmentLabel, environmentFullTitle } from "~/components/environments/EnvironmentLabel";
1925
import { Button, LinkButton } from "~/components/primitives/Buttons";
2026
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
2127
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
@@ -41,7 +47,7 @@ import { useList } from "~/hooks/useList";
4147
import { useOrganization } from "~/hooks/useOrganizations";
4248
import { useProject } from "~/hooks/useProject";
4349
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
44-
import { requireUserId } from "~/services/session.server";
50+
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
4551
import { cn } from "~/utils/cn";
4652
import {
4753
environmentVariablesRouteId,
@@ -95,74 +101,107 @@ const schema = z.object({
95101
}, Variable.array().nonempty("At least one variable is required")),
96102
});
97103

98-
export const action = async ({ request, params }: ActionFunctionArgs) => {
99-
const userId = await requireUserId(request);
100-
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
104+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
105+
const org = await prisma.organization.findFirst({ where: { slug }, select: { id: true } });
106+
return org?.id ?? null;
107+
}
101108

102-
if (request.method.toUpperCase() !== "POST") {
103-
return { status: 405, body: "Method Not Allowed" };
104-
}
109+
export const action = dashboardAction(
110+
{
111+
params: EnvironmentParamSchema,
112+
context: async (params) => {
113+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
114+
return organizationId ? { organizationId } : {};
115+
},
116+
// Per-environment write:envvars is enforced in the handler — the target
117+
// environments come from the submission, not the route params.
118+
},
119+
async ({ request, params, user, ability }) => {
120+
const userId = user.id;
121+
const { organizationSlug, projectParam, envParam } = params;
122+
123+
if (request.method.toUpperCase() !== "POST") {
124+
throw new Response("Method Not Allowed", { status: 405 });
125+
}
105126

106-
const formData = await request.formData();
107-
const submission = parse(formData, { schema });
127+
const formData = await request.formData();
128+
const submission = parse(formData, { schema });
108129

109-
if (!submission.value) {
110-
return json(submission);
111-
}
130+
if (!submission.value) {
131+
return json(submission);
132+
}
133+
134+
// Enforce env-tier write:envvars for every targeted environment, so a role
135+
// that can't write a deployed tier can't create vars there via a direct
136+
// POST (the disabled checkboxes are not the boundary).
137+
const targetEnvironments = await prisma.runtimeEnvironment.findMany({
138+
where: { id: { in: submission.value.environmentIds } },
139+
select: { type: true },
140+
});
141+
const hasDeniedEnvironment = targetEnvironments.some(
142+
(env) => !ability.can("write", { type: "envvars", envType: env.type })
143+
);
144+
if (hasDeniedEnvironment) {
145+
submission.error.environmentIds = [
146+
"You don't have permission to manage environment variables in one of the selected environments.",
147+
];
148+
return json(submission);
149+
}
112150

113-
const project = await prisma.project.findUnique({
114-
where: {
115-
slug: params.projectParam,
116-
organization: {
117-
members: {
118-
some: {
119-
userId,
151+
const project = await prisma.project.findUnique({
152+
where: {
153+
slug: params.projectParam,
154+
organization: {
155+
members: {
156+
some: {
157+
userId,
158+
},
120159
},
121160
},
122161
},
123-
},
124-
select: {
125-
id: true,
126-
},
127-
});
128-
if (!project) {
129-
submission.error.key = ["Project not found"];
130-
return json(submission);
131-
}
162+
select: {
163+
id: true,
164+
},
165+
});
166+
if (!project) {
167+
submission.error.key = ["Project not found"];
168+
return json(submission);
169+
}
132170

133-
const repository = new EnvironmentVariablesRepository(prisma);
134-
const result = await repository.create(project.id, {
135-
...submission.value,
136-
lastUpdatedBy: {
137-
type: "user",
138-
userId,
139-
},
140-
});
171+
const repository = new EnvironmentVariablesRepository(prisma);
172+
const result = await repository.create(project.id, {
173+
...submission.value,
174+
lastUpdatedBy: {
175+
type: "user",
176+
userId,
177+
},
178+
});
141179

142-
if (!result.success) {
143-
if (result.variableErrors) {
144-
for (const { key, error } of result.variableErrors) {
145-
const index = submission.value.variables.findIndex((v) => v.key === key);
180+
if (!result.success) {
181+
if (result.variableErrors) {
182+
for (const { key, error } of result.variableErrors) {
183+
const index = submission.value.variables.findIndex((v) => v.key === key);
146184

147-
if (index !== -1) {
148-
submission.error[`variables[${index}].key`] = [error];
185+
if (index !== -1) {
186+
submission.error[`variables[${index}].key`] = [error];
187+
}
149188
}
189+
} else {
190+
submission.error.variables = [result.error];
150191
}
151-
} else {
152-
submission.error.variables = [result.error];
192+
193+
return json(submission);
153194
}
154195

155-
return json(submission);
196+
return redirect(
197+
v3EnvironmentVariablesPath(
198+
{ slug: organizationSlug },
199+
{ slug: projectParam },
200+
{ slug: envParam }
201+
)
202+
);
156203
}
157-
158-
return redirect(
159-
v3EnvironmentVariablesPath(
160-
{ slug: organizationSlug },
161-
{ slug: projectParam },
162-
{ slug: envParam }
163-
)
164-
);
165-
};
204+
);
166205

167206
export default function Page() {
168207
const [isOpen, setIsOpen] = useState(true);
@@ -173,7 +212,8 @@ export default function Page() {
173212
parentData,
174213
"Environment variables page loader data must be defined when rendering the create dialog"
175214
);
176-
const { environments, hasStaging } = parentData;
215+
const { environments, hasStaging, accessibleEnvironmentIds } = parentData;
216+
const accessibleEnvironmentIdSet = new Set(accessibleEnvironmentIds);
177217
const lastSubmission = useActionData();
178218
const navigation = useNavigation();
179219
const navigate = useNavigate();
@@ -269,19 +309,45 @@ export default function Page() {
269309
))
270310
)}
271311
<div className="flex items-center gap-2">
272-
{nonBranchEnvironments.map((environment) => (
273-
<CheckboxWithLabel
274-
key={environment.id}
275-
id={environment.id}
276-
value={environment.id}
277-
defaultChecked={selectedEnvironmentIds.has(environment.id)}
278-
onChange={(isChecked) =>
279-
handleEnvironmentChange(environment.id, isChecked, environment.type)
280-
}
281-
label={<EnvironmentLabel environment={environment} className="text-sm" />}
282-
variant="button"
283-
/>
284-
))}
312+
{nonBranchEnvironments.map((environment) =>
313+
accessibleEnvironmentIdSet.has(environment.id) ? (
314+
<CheckboxWithLabel
315+
key={environment.id}
316+
id={environment.id}
317+
value={environment.id}
318+
defaultChecked={selectedEnvironmentIds.has(environment.id)}
319+
onChange={(isChecked) =>
320+
handleEnvironmentChange(environment.id, isChecked, environment.type)
321+
}
322+
label={<EnvironmentLabel environment={environment} className="text-sm" />}
323+
variant="button"
324+
/>
325+
) : (
326+
<TooltipProvider key={environment.id}>
327+
<Tooltip>
328+
<TooltipTrigger asChild>
329+
<div>
330+
<CheckboxWithLabel
331+
id={environment.id}
332+
value={environment.id}
333+
disabled
334+
defaultChecked={false}
335+
label={
336+
<EnvironmentLabel environment={environment} className="text-sm" />
337+
}
338+
variant="button"
339+
/>
340+
</div>
341+
</TooltipTrigger>
342+
<TooltipContent className="flex items-center gap-2">
343+
<NoSymbolIcon className="size-4 text-text-dimmed" />
344+
With your current role, you can't manage{" "}
345+
{environmentFullTitle(environment)} environment variables.
346+
</TooltipContent>
347+
</Tooltip>
348+
</TooltipProvider>
349+
)
350+
)}
285351
{!hasStaging && (
286352
<>
287353
<TooltipProvider>

0 commit comments

Comments
 (0)