Skip to content

Commit f7c2fa8

Browse files
nicktrnTrigger.dev RepoOps
authored andcommitted
feat(webapp): option to revoke the old root key immediately
Mono-RevId: 731f6f0d3c20721d77c9f9617851865c1c455ff2
1 parent 6f86da8 commit f7c2fa8

8 files changed

Lines changed: 204 additions & 13 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
When regenerating an environment's API key you can now revoke the previous key immediately instead of keeping it working for 24 hours. Use this when a key may have leaked.

‎apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Input } from "../primitives/Input";
1111
import { InputGroup } from "../primitives/InputGroup";
1212
import { Paragraph } from "../primitives/Paragraph";
1313
import { CheckboxWithLabel } from "../primitives/Checkbox";
14+
import { RadioGroup, RadioGroupItem } from "../primitives/RadioButton";
1415
import { Spinner } from "../primitives/Spinner";
1516

1617
type ModalProps = {
@@ -75,16 +76,32 @@ const RegenerateApiKeyModalContent = ({
7576
return (
7677
<div className="flex flex-col items-center gap-y-4 pt-4">
7778
<Callout variant="warning">
78-
{`A new API key will be issued for the ${title} environment. The previous key stays valid
79-
for 24 hours so you can roll out the new key in your environment variables without downtime.
80-
After 24 hours, the previous key stops working.`}
79+
{`A new API key will be issued for the ${title} environment. Choose what happens to the previous key.`}
8180
</Callout>
8281
<fetcher.Form
8382
method="post"
8483
action={`/resources/environments/${id}/regenerate-api-key`}
8584
className="mt-2 w-full"
8685
>
8786
<Fieldset className="w-full">
87+
<InputGroup className="max-w-full">
88+
<RadioGroup name="previousKey" defaultValue="grace" className="flex flex-col gap-2">
89+
<RadioGroupItem
90+
id="previousKey-grace"
91+
value="grace"
92+
variant="description"
93+
label="Keep the previous key working for 24 hours"
94+
description="Roll out the new key to your environment variables without downtime. The previous key stops working after 24 hours."
95+
/>
96+
<RadioGroupItem
97+
id="previousKey-revoke"
98+
value="revoke"
99+
variant="description"
100+
label="Revoke the previous key immediately"
101+
description="Choose this if the key may have leaked. Deployments and services still using the previous key will fail until they are updated with the new key."
102+
/>
103+
</RadioGroup>
104+
</InputGroup>
88105
<InputGroup className="max-w-full">
89106
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
90107
<Paragraph

‎apps/webapp/app/models/api-key.server.ts‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ type RootApiKeyMutationInput = {
2525
environmentId: string;
2626
};
2727

28+
type RegenerateApiKeyInput = RootApiKeyMutationInput & {
29+
revokeImmediately?: boolean;
30+
};
31+
2832
export class RootApiKeyNotVisibleError extends Error {
2933
constructor() {
3034
super("The root API key is no longer visible");
@@ -110,7 +114,7 @@ async function lockVisibleRootApiKeyEnvironment(
110114
}
111115

112116
export async function regenerateApiKey(
113-
input: RootApiKeyMutationInput,
117+
{ revokeImmediately = false, ...input }: RegenerateApiKeyInput,
114118
{ prismaClient = prisma }: { prismaClient?: PrismaClient } = {}
115119
) {
116120
const environment = await findRootApiKeyEnvironment(input, prismaClient);
@@ -132,13 +136,15 @@ export async function regenerateApiKey(
132136
},
133137
});
134138

135-
await tx.revokedApiKey.create({
136-
data: {
137-
apiKey: currentEnvironment.apiKey,
138-
runtimeEnvironmentId: environment.id,
139-
expiresAt: revokedApiKeyExpiresAt,
140-
},
141-
});
139+
if (!revokeImmediately) {
140+
await tx.revokedApiKey.create({
141+
data: {
142+
apiKey: currentEnvironment.apiKey,
143+
runtimeEnvironmentId: environment.id,
144+
expiresAt: revokedApiKeyExpiresAt,
145+
},
146+
});
147+
}
142148

143149
return { ...environment, apiKey: newApiKey, pkApiKey: newPkApiKey };
144150
}

‎apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const ParamsSchema = z.object({
1414
env: z.enum(["dev", "staging", "prod", "preview"]),
1515
});
1616

17+
const BodySchema = z.object({
18+
revokeImmediately: z.boolean().default(false),
19+
});
20+
1721
export async function action({ request, params }: ActionFunctionArgs) {
1822
if (request.method.toUpperCase() !== "POST") {
1923
return json({ error: "Method Not Allowed" }, { status: 405 });
@@ -42,6 +46,24 @@ export async function action({ request, params }: ActionFunctionArgs) {
4246
return json({ error: "Branch-scoped key rotation is not supported" }, { status: 400 });
4347
}
4448

49+
const rawBody = await request.text();
50+
let body: unknown = {};
51+
if (rawBody.trim().length > 0) {
52+
try {
53+
body = JSON.parse(rawBody);
54+
} catch {
55+
return json({ error: "Invalid JSON body" }, { status: 400 });
56+
}
57+
}
58+
59+
const parsedBody = BodySchema.safeParse(body);
60+
61+
if (!parsedBody.success) {
62+
return json({ error: "Invalid Body" }, { status: 400 });
63+
}
64+
65+
const { revokeImmediately } = parsedBody.data;
66+
4567
const environment = await authenticatedEnvironmentForAuthentication(
4668
authenticationResult,
4769
projectRef,
@@ -65,6 +87,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
6587
const updatedEnvironment = await regenerateApiKey({
6688
userId: authenticationResult.result.userId,
6789
environmentId: environment.id,
90+
revokeImmediately,
6891
});
6992

7093
return json({ apiKey: updatedEnvironment.apiKey });

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,14 @@ export const action = dashboardAction(
5252

5353
const formData = await request.formData();
5454
const syncToVercel = formData.get("syncToVercel") === "on";
55+
const revokeImmediately = formData.get("previousKey") === "revoke";
5556

5657
try {
57-
const updatedEnvironment = await regenerateApiKey({ userId: user.id, environmentId });
58+
const updatedEnvironment = await regenerateApiKey({
59+
userId: user.id,
60+
environmentId,
61+
revokeImmediately,
62+
});
5863

5964
// Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT
6065
if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") {

‎apps/webapp/test/helpers/seedTestUserProject.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ export async function seedTestUserProject(
3838
isActivated: true,
3939
members: { create: { userId: user.id, role: "ADMIN" } },
4040
},
41+
include: { members: true },
4142
});
43+
const orgMember = organization.members[0];
4244

4345
const project = await prisma.project.create({
4446
data: {
@@ -60,6 +62,7 @@ export async function seedTestUserProject(
6062
shortcode: suffix.slice(0, 4),
6163
projectId: project.id,
6264
organizationId: organization.id,
65+
orgMemberId: orgMember.id,
6366
},
6467
});
6568

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { containerTest } from "@internal/testcontainers";
2+
import { expect, vi } from "vitest";
3+
import { regenerateApiKey } from "~/models/api-key.server";
4+
import { findEnvironmentByApiKey } from "~/models/runtimeEnvironment.server";
5+
import {
6+
createRuntimeEnvironment,
7+
createTestOrgProjectWithMember,
8+
} from "./fixtures/environmentVariablesFixtures";
9+
10+
vi.setConfig({ testTimeout: 60_000 });
11+
12+
containerTest(
13+
"regenerating keeps the previous root key valid for a grace period",
14+
async ({ prisma }) => {
15+
const { organization, project, user } = await createTestOrgProjectWithMember(prisma);
16+
const environment = await createRuntimeEnvironment(prisma, {
17+
projectId: project.id,
18+
organizationId: organization.id,
19+
type: "PRODUCTION",
20+
});
21+
const oldRootApiKey = environment.apiKey;
22+
23+
const result = await regenerateApiKey(
24+
{ environmentId: environment.id, userId: user.id },
25+
{ prismaClient: prisma }
26+
);
27+
28+
expect(result.apiKey).not.toBe(oldRootApiKey);
29+
const revoked = await prisma.revokedApiKey.findMany({
30+
where: { runtimeEnvironmentId: environment.id },
31+
});
32+
expect(revoked).toHaveLength(1);
33+
expect(revoked[0].apiKey).toBe(oldRootApiKey);
34+
expect(revoked[0].expiresAt.getTime()).toBeGreaterThan(Date.now());
35+
await expect(findEnvironmentByApiKey(oldRootApiKey, undefined, prisma)).resolves.toMatchObject({
36+
id: environment.id,
37+
});
38+
await expect(findEnvironmentByApiKey(result.apiKey, undefined, prisma)).resolves.toMatchObject({
39+
id: environment.id,
40+
});
41+
}
42+
);
43+
44+
containerTest(
45+
"regenerating with revokeImmediately rejects the previous root key at once",
46+
async ({ prisma }) => {
47+
const { organization, project, user } = await createTestOrgProjectWithMember(prisma);
48+
const environment = await createRuntimeEnvironment(prisma, {
49+
projectId: project.id,
50+
organizationId: organization.id,
51+
type: "PRODUCTION",
52+
});
53+
const oldRootApiKey = environment.apiKey;
54+
55+
const result = await regenerateApiKey(
56+
{ environmentId: environment.id, userId: user.id, revokeImmediately: true },
57+
{ prismaClient: prisma }
58+
);
59+
60+
expect(result.apiKey).not.toBe(oldRootApiKey);
61+
const revoked = await prisma.revokedApiKey.findMany({
62+
where: { runtimeEnvironmentId: environment.id },
63+
});
64+
expect(revoked).toHaveLength(0);
65+
await expect(findEnvironmentByApiKey(oldRootApiKey, undefined, prisma)).resolves.toBeNull();
66+
await expect(findEnvironmentByApiKey(result.apiKey, undefined, prisma)).resolves.toMatchObject({
67+
id: environment.id,
68+
});
69+
}
70+
);

‎apps/webapp/test/regenerateApiKeyBranch.e2e.full.test.ts‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getTestServer } from "./helpers/sharedTestServer";
33
import { seedTestUserProject } from "./helpers/seedTestUserProject";
44

55
describe("regenerate API key branch handling", () => {
6-
it.each(["feature", "", "default"])(
6+
it.each(["feature", "default"])(
77
"rejects a present branch header without rotating the root key (%s)",
88
async (branch) => {
99
const server = getTestServer();
@@ -53,4 +53,65 @@ describe("regenerate API key branch handling", () => {
5353
});
5454
expect(revoked?.expiresAt.getTime()).toBeGreaterThan(Date.now());
5555
});
56+
57+
it("revokes the previous key immediately when asked", async () => {
58+
const server = getTestServer();
59+
const { environment, pat, project } = await seedTestUserProject(server.prisma);
60+
const apiKeyRoute = "/api/v1/runs/run_doesnotexist/result";
61+
62+
const response = await server.webapp.fetch(
63+
`/api/v1/projects/${project.externalRef}/dev/regenerate-api-key`,
64+
{
65+
method: "POST",
66+
headers: {
67+
Authorization: `Bearer ${pat.token}`,
68+
"Content-Type": "application/json",
69+
},
70+
body: JSON.stringify({ revokeImmediately: true }),
71+
}
72+
);
73+
74+
expect(response.status).toBe(200);
75+
const body = (await response.json()) as { apiKey: string };
76+
expect(body.apiKey).not.toBe(environment.apiKey);
77+
78+
const revoked = await server.prisma.revokedApiKey.findFirst({
79+
where: { apiKey: environment.apiKey, runtimeEnvironmentId: environment.id },
80+
});
81+
expect(revoked).toBeNull();
82+
83+
const oldKeyResponse = await server.webapp.fetch(apiKeyRoute, {
84+
headers: { Authorization: `Bearer ${environment.apiKey}` },
85+
});
86+
expect(oldKeyResponse.status).toBe(401);
87+
88+
const newKeyResponse = await server.webapp.fetch(apiKeyRoute, {
89+
headers: { Authorization: `Bearer ${body.apiKey}` },
90+
});
91+
expect(newKeyResponse.status).not.toBe(401);
92+
});
93+
94+
it("rejects a malformed body without rotating the root key", async () => {
95+
const server = getTestServer();
96+
const { environment, pat, project } = await seedTestUserProject(server.prisma);
97+
98+
const response = await server.webapp.fetch(
99+
`/api/v1/projects/${project.externalRef}/dev/regenerate-api-key`,
100+
{
101+
method: "POST",
102+
headers: {
103+
Authorization: `Bearer ${pat.token}`,
104+
"Content-Type": "application/json",
105+
},
106+
body: JSON.stringify({ revokeImmediately: "yes" }),
107+
}
108+
);
109+
110+
expect(response.status).toBe(400);
111+
const persisted = await server.prisma.runtimeEnvironment.findUniqueOrThrow({
112+
where: { id: environment.id },
113+
select: { apiKey: true },
114+
});
115+
expect(persisted.apiKey).toBe(environment.apiKey);
116+
});
56117
});

0 commit comments

Comments
 (0)