Skip to content

Commit dbca7f4

Browse files
committed
feat(webapp): gate API keys page on env-tier read access
Add a reusable PermissionDenied panel and use it on the API keys page: when a role can't read a given environment's secret key (e.g. deployed environments for a restricted role), the secret is withheld server-side and the page renders the panel instead. Regenerating an API key is gated the same way, enforced on the POST so a disabled button isn't the only guard.
1 parent 53e5947 commit dbca7f4

3 files changed

Lines changed: 199 additions & 113 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { organizationRolesPath } from "~/utils/pathBuilder";
2+
import { LinkButton } from "./primitives/Buttons";
3+
import { InfoPanel } from "./primitives/InfoPanel";
4+
import { LockClosedIcon } from "@heroicons/react/20/solid";
5+
import { useOrganization } from "~/hooks/useOrganizations";
6+
import React from "react";
7+
8+
export function PermissionDenied({ message }: { message: React.ReactNode }) {
9+
const organization = useOrganization();
10+
11+
return (
12+
<InfoPanel
13+
icon={LockClosedIcon}
14+
iconClassName="text-text-dimmed"
15+
title="Permission denied"
16+
accessory={
17+
<LinkButton to={organizationRolesPath(organization)} variant="secondary/small">
18+
View roles
19+
</LinkButton>
20+
}
21+
>
22+
{message}
23+
</InfoPanel>
24+
);
25+
}

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

Lines changed: 109 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { BookOpenIcon } from "@heroicons/react/20/solid";
22
import { type MetaFunction } from "@remix-run/react";
3-
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
43
import { typedjson, useTypedLoaderData } from "remix-typedjson";
54
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
65
import { CodeBlock } from "~/components/code/CodeBlock";
@@ -16,6 +15,7 @@ import {
1615
PageBody,
1716
PageContainer,
1817
} from "~/components/layout/AppLayout";
18+
import { PermissionDenied } from "~/components/PermissionDenied";
1919
import {
2020
Accordion,
2121
AccordionContent,
@@ -31,9 +31,10 @@ import { InputGroup } from "~/components/primitives/InputGroup";
3131
import { Label } from "~/components/primitives/Label";
3232
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
3333
import * as Property from "~/components/primitives/PropertyTable";
34+
import { $replica } from "~/db.server";
3435
import { useOrganization } from "~/hooks/useOrganizations";
3536
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
36-
import { requireUserId } from "~/services/session.server";
37+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
3738
import { cn } from "~/utils/cn";
3839
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
3940

@@ -45,33 +46,55 @@ export const meta: MetaFunction = () => {
4546
];
4647
};
4748

48-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
49-
const userId = await requireUserId(request);
50-
const { projectParam, envParam } = EnvironmentParamSchema.parse(params);
49+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
50+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
51+
return org?.id ?? null;
52+
}
5153

52-
try {
53-
const presenter = new ApiKeysPresenter();
54-
const { environment, hasVercelIntegration } = await presenter.call({
55-
userId,
56-
projectSlug: projectParam,
57-
environmentSlug: envParam,
58-
});
54+
export const loader = dashboardLoader(
55+
{
56+
params: EnvironmentParamSchema,
57+
context: async (params) => {
58+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
59+
return organizationId ? { organizationId } : {};
60+
},
61+
// No hard authorization: anyone with project access can open the page.
62+
// Reading the secret key is gated per environment tier below — a role
63+
// that can't read this tier's keys gets the info panel, not the key.
64+
},
65+
async ({ params, user, ability }) => {
66+
const { projectParam, envParam } = params;
5967

60-
return typedjson({
61-
environment,
62-
hasVercelIntegration,
63-
});
64-
} catch (error) {
65-
console.error(error);
66-
throw new Response(undefined, {
67-
status: 400,
68-
statusText: "Something went wrong, if this problem persists please contact support.",
69-
});
68+
try {
69+
const presenter = new ApiKeysPresenter();
70+
const { environment, hasVercelIntegration } = await presenter.call({
71+
userId: user.id,
72+
projectSlug: projectParam,
73+
environmentSlug: envParam,
74+
});
75+
76+
const canReadApiKeys =
77+
!environment || ability.can("read", { type: "apiKeys", envType: environment.type });
78+
79+
return typedjson({
80+
// Never serialize the secret key to the client when the role can't
81+
// read it for this environment tier.
82+
environment: environment && !canReadApiKeys ? { ...environment, apiKey: "" } : environment,
83+
hasVercelIntegration,
84+
canReadApiKeys,
85+
});
86+
} catch (error) {
87+
console.error(error);
88+
throw new Response(undefined, {
89+
status: 400,
90+
statusText: "Something went wrong, if this problem persists please contact support.",
91+
});
92+
}
7093
}
71-
};
94+
);
7295

7396
export default function Page() {
74-
const { environment, hasVercelIntegration } = useTypedLoaderData<typeof loader>();
97+
const { environment, hasVercelIntegration, canReadApiKeys } = useTypedLoaderData<typeof loader>();
7598
const organization = useOrganization();
7699

77100
if (!environment) {
@@ -126,70 +149,78 @@ export default function Page() {
126149
API keys
127150
</Header2>
128151
</div>
129-
<div className="flex flex-col gap-6">
130-
<InputGroup fullWidth>
131-
<div className="flex w-full items-center justify-between">
132-
<Label>Secret key</Label>
133-
<RegenerateApiKeyModal
134-
id={environment.parentEnvironment?.id ?? environment.id}
135-
title={environmentFullTitle(environment)}
136-
hasVercelIntegration={hasVercelIntegration}
137-
isDevelopment={environment.type === "DEVELOPMENT"}
138-
/>
139-
</div>
140-
<ClipboardField
141-
className="w-full max-w-none"
142-
secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
143-
value={environment.apiKey}
144-
variant={"secondary/small"}
145-
/>
146-
<Hint>
147-
Set this as your <InlineCode variant="extra-small">TRIGGER_SECRET_KEY</InlineCode>{" "}
148-
env var in your backend.
149-
</Hint>
150-
</InputGroup>
151-
{environment.branchName && (
152+
{canReadApiKeys ? (
153+
<div className="flex flex-col gap-6">
152154
<InputGroup fullWidth>
153-
<Label>Branch name</Label>
155+
<div className="flex w-full items-center justify-between">
156+
<Label>Secret key</Label>
157+
<RegenerateApiKeyModal
158+
id={environment.parentEnvironment?.id ?? environment.id}
159+
title={environmentFullTitle(environment)}
160+
hasVercelIntegration={hasVercelIntegration}
161+
isDevelopment={environment.type === "DEVELOPMENT"}
162+
/>
163+
</div>
154164
<ClipboardField
155165
className="w-full max-w-none"
156-
value={environment.branchName}
166+
secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
167+
value={environment.apiKey}
157168
variant={"secondary/small"}
158169
/>
159170
<Hint>
160-
Set this as your{" "}
161-
<InlineCode variant="extra-small">TRIGGER_PREVIEW_BRANCH</InlineCode> env var in
162-
your backend.
171+
Set this as your <InlineCode variant="extra-small">TRIGGER_SECRET_KEY</InlineCode>{" "}
172+
env var in your backend.
163173
</Hint>
164174
</InputGroup>
165-
)}
166-
{environment.type === "DEVELOPMENT" && (
167-
<Callout variant="info">
168-
Every team member gets their own dev Secret key. Make sure you're using the one
169-
above otherwise you will trigger runs on your team member's machine.
170-
</Callout>
171-
)}
175+
{environment.branchName && (
176+
<InputGroup fullWidth>
177+
<Label>Branch name</Label>
178+
<ClipboardField
179+
className="w-full max-w-none"
180+
value={environment.branchName}
181+
variant={"secondary/small"}
182+
/>
183+
<Hint>
184+
Set this as your{" "}
185+
<InlineCode variant="extra-small">TRIGGER_PREVIEW_BRANCH</InlineCode> env var in
186+
your backend.
187+
</Hint>
188+
</InputGroup>
189+
)}
190+
{environment.type === "DEVELOPMENT" && (
191+
<Callout variant="info">
192+
Every team member gets their own dev Secret key. Make sure you're using the one
193+
above otherwise you will trigger runs on your team member's machine.
194+
</Callout>
195+
)}
172196

173-
<Accordion type="single" collapsible>
174-
<AccordionItem value="item-1">
175-
<AccordionTrigger>How to set these environment variables</AccordionTrigger>
176-
<AccordionContent>
177-
<div className="flex flex-col gap-2">
178-
<div>
179-
You need to set these environment variables in your backend. This allows the
180-
SDK to authenticate with Trigger.dev.
197+
<Accordion type="single" collapsible>
198+
<AccordionItem value="item-1">
199+
<AccordionTrigger>How to set these environment variables</AccordionTrigger>
200+
<AccordionContent>
201+
<div className="flex flex-col gap-2">
202+
<div>
203+
You need to set these environment variables in your backend. This allows the
204+
SDK to authenticate with Trigger.dev.
205+
</div>
206+
<CodeBlock
207+
language="javascript"
208+
code={envBlock}
209+
showOpenInModal={false}
210+
showLineNumbers={false}
211+
/>
181212
</div>
182-
<CodeBlock
183-
language="javascript"
184-
code={envBlock}
185-
showOpenInModal={false}
186-
showLineNumbers={false}
187-
/>
188-
</div>
189-
</AccordionContent>
190-
</AccordionItem>
191-
</Accordion>
192-
</div>
213+
</AccordionContent>
214+
</AccordionItem>
215+
</Accordion>
216+
</div>
217+
) : (
218+
<PermissionDenied
219+
message={`With your current role, you can't view the API keys for ${environmentFullTitle(
220+
environment
221+
)}.`}
222+
/>
223+
)}
193224
</MainHorizontallyCenteredContainer>
194225
</PageBody>
195226
</PageContainer>

apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,86 @@
1-
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
21
import { z } from "zod";
32
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
3+
import { $replica } from "~/db.server";
44
import { regenerateApiKey } from "~/models/api-key.server";
5-
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
65
import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server";
7-
import { requireUserId } from "~/services/session.server";
6+
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
87
import { logger } from "~/services/logger.server";
8+
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
99

1010
const ParamsSchema = z.object({
1111
environmentId: z.string(),
1212
});
1313

14-
export async function action({ request, params }: ActionFunctionArgs) {
15-
// Ensure this is a POST request
16-
if (request.method.toUpperCase() !== "POST") {
17-
return { status: 405, body: "Method Not Allowed" };
18-
}
19-
20-
const userId = await requireUserId(request);
21-
22-
const { environmentId } = ParamsSchema.parse(params);
14+
export const action = dashboardAction(
15+
{
16+
params: ParamsSchema,
17+
context: async (params) => {
18+
const environment = await $replica.runtimeEnvironment.findFirst({
19+
where: { id: params.environmentId },
20+
select: { organizationId: true },
21+
});
22+
return environment ? { organizationId: environment.organizationId } : {};
23+
},
24+
// Env-tier write:apiKeys is enforced in the handler — the target
25+
// environment's tier isn't known until we resolve it from the id.
26+
},
27+
async ({ request, params, user, ability }) => {
28+
if (request.method.toUpperCase() !== "POST") {
29+
throw new Response("Method Not Allowed", { status: 405 });
30+
}
2331

24-
const formData = await request.formData();
25-
const syncToVercel = formData.get("syncToVercel") === "on";
32+
const { environmentId } = params;
2633

27-
try {
28-
const updatedEnvironment = await regenerateApiKey({ userId, environmentId });
34+
const environment = await $replica.runtimeEnvironment.findFirst({
35+
where: { id: environmentId },
36+
select: { type: true },
37+
});
38+
if (!environment) {
39+
return jsonWithErrorMessage({ ok: false }, request, "Environment not found");
40+
}
2941

30-
// Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT
31-
if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") {
32-
await syncApiKeyToVercel(
33-
updatedEnvironment.projectId,
34-
updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW",
35-
updatedEnvironment.apiKey
42+
// Gate the regenerate even on a direct POST: a role that can't write
43+
// this tier's API keys can't rotate them. The disabled UI control is
44+
// not the boundary; this check is.
45+
if (!ability.can("write", { type: "apiKeys", envType: environment.type })) {
46+
return jsonWithErrorMessage(
47+
{ ok: false },
48+
request,
49+
"You don't have permission to regenerate API keys for this environment."
3650
);
3751
}
3852

39-
return jsonWithSuccessMessage(
40-
{ ok: true },
41-
request,
42-
`API keys regenerated for ${environmentFullTitle(updatedEnvironment)} environment`
43-
);
44-
} catch (error) {
45-
const message = error instanceof Error ? error.message : "Unknown error";
53+
const formData = await request.formData();
54+
const syncToVercel = formData.get("syncToVercel") === "on";
4655

47-
return jsonWithErrorMessage(
48-
{ ok: false },
49-
request,
50-
`API keys could not be regenerated: ${message}`
51-
);
56+
try {
57+
const updatedEnvironment = await regenerateApiKey({ userId: user.id, environmentId });
58+
59+
// Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT
60+
if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") {
61+
await syncApiKeyToVercel(
62+
updatedEnvironment.projectId,
63+
updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW",
64+
updatedEnvironment.apiKey
65+
);
66+
}
67+
68+
return jsonWithSuccessMessage(
69+
{ ok: true },
70+
request,
71+
`API keys regenerated for ${environmentFullTitle(updatedEnvironment)} environment`
72+
);
73+
} catch (error) {
74+
const message = error instanceof Error ? error.message : "Unknown error";
75+
76+
return jsonWithErrorMessage(
77+
{ ok: false },
78+
request,
79+
`API keys could not be regenerated: ${message}`
80+
);
81+
}
5282
}
53-
}
83+
);
5484

5585
/**
5686
* Sync the API key to Vercel.

0 commit comments

Comments
 (0)