GeezerVison is showing github.com — feat(webapp): option to revoke the old root key immediately · triggerdotdev/trigger.dev@f7c2fa8
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboard +6Lines changed: 6 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
| | 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. |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx +20-3Lines changed: 20 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -11,6 +11,7 @@ import { Input } from "../primitives/Input"; | 11 | 11 | import { InputGroup } from "../primitives/InputGroup"; | 12 | 12 | import { Paragraph } from "../primitives/Paragraph"; | 13 | 13 | import { CheckboxWithLabel } from "../primitives/Checkbox"; | | 14 | +import { RadioGroup, RadioGroupItem } from "../primitives/RadioButton"; | 14 | 15 | import { Spinner } from "../primitives/Spinner"; | 15 | 16 |
| 16 | 17 | type ModalProps = { | @@ -75,16 +76,32 @@ const RegenerateApiKeyModalContent = ({ | 75 | 76 | return ( | 76 | 77 | <div className="flex flex-col items-center gap-y-4 pt-4"> | 77 | 78 | <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.`} | 81 | 80 | </Callout> | 82 | 81 | <fetcher.Form | 83 | 82 | method="post" | 84 | 83 | action={`/resources/environments/${id}/regenerate-api-key`} | 85 | 84 | className="mt-2 w-full" | 86 | 85 | > | 87 | 86 | <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> | 88 | 105 | <InputGroup className="max-w-full"> | 89 | 106 | <Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph> | 90 | 107 | <Paragraph | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/models/api-key.server.ts +14-8Lines changed: 14 additions & 8 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -25,6 +25,10 @@ type RootApiKeyMutationInput = { | 25 | 25 | environmentId: string; | 26 | 26 | }; | 27 | 27 |
| | 28 | +type RegenerateApiKeyInput = RootApiKeyMutationInput & { | | 29 | + revokeImmediately?: boolean; | | 30 | +}; | | 31 | + | 28 | 32 | export class RootApiKeyNotVisibleError extends Error { | 29 | 33 | constructor() { | 30 | 34 | super("The root API key is no longer visible"); | @@ -110,7 +114,7 @@ async function lockVisibleRootApiKeyEnvironment( | 110 | 114 | } | 111 | 115 |
| 112 | 116 | export async function regenerateApiKey( | 113 | | - input: RootApiKeyMutationInput, | | 117 | + { revokeImmediately = false, ...input }: RegenerateApiKeyInput, | 114 | 118 | { prismaClient = prisma }: { prismaClient?: PrismaClient } = {} | 115 | 119 | ) { | 116 | 120 | const environment = await findRootApiKeyEnvironment(input, prismaClient); | @@ -132,13 +136,15 @@ export async function regenerateApiKey( | 132 | 136 | }, | 133 | 137 | }); | 134 | 138 |
| 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 | + } | 142 | 148 |
| 143 | 149 | return { ...environment, apiKey: newApiKey, pkApiKey: newPkApiKey }; | 144 | 150 | } | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts +23Lines changed: 23 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -14,6 +14,10 @@ const ParamsSchema = z.object({ | 14 | 14 | env: z.enum(["dev", "staging", "prod", "preview"]), | 15 | 15 | }); | 16 | 16 |
| | 17 | +const BodySchema = z.object({ | | 18 | + revokeImmediately: z.boolean().default(false), | | 19 | +}); | | 20 | + | 17 | 21 | export async function action({ request, params }: ActionFunctionArgs) { | 18 | 22 | if (request.method.toUpperCase() !== "POST") { | 19 | 23 | return json({ error: "Method Not Allowed" }, { status: 405 }); | @@ -42,6 +46,24 @@ export async function action({ request, params }: ActionFunctionArgs) { | 42 | 46 | return json({ error: "Branch-scoped key rotation is not supported" }, { status: 400 }); | 43 | 47 | } | 44 | 48 |
| | 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 | + | 45 | 67 | const environment = await authenticatedEnvironmentForAuthentication( | 46 | 68 | authenticationResult, | 47 | 69 | projectRef, | @@ -65,6 +87,7 @@ export async function action({ request, params }: ActionFunctionArgs) { | 65 | 87 | const updatedEnvironment = await regenerateApiKey({ | 66 | 88 | userId: authenticationResult.result.userId, | 67 | 89 | environmentId: environment.id, | | 90 | + revokeImmediately, | 68 | 91 | }); | 69 | 92 |
| 70 | 93 | return json({ apiKey: updatedEnvironment.apiKey }); | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx +6-1Lines changed: 6 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -52,9 +52,14 @@ export const action = dashboardAction( | 52 | 52 |
| 53 | 53 | const formData = await request.formData(); | 54 | 54 | const syncToVercel = formData.get("syncToVercel") === "on"; | | 55 | + const revokeImmediately = formData.get("previousKey") === "revoke"; | 55 | 56 |
| 56 | 57 | try { | 57 | | - const updatedEnvironment = await regenerateApiKey({ userId: user.id, environmentId }); | | 58 | + const updatedEnvironment = await regenerateApiKey({ | | 59 | + userId: user.id, | | 60 | + environmentId, | | 61 | + revokeImmediately, | | 62 | + }); | 58 | 63 |
| 59 | 64 | // Sync the regenerated API key to Vercel only when requested and not for DEVELOPMENT | 60 | 65 | if (syncToVercel && updatedEnvironment.type !== "DEVELOPMENT") { | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/test/helpers/seedTestUserProject.ts +3Lines changed: 3 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -38,7 +38,9 @@ export async function seedTestUserProject( | 38 | 38 | isActivated: true, | 39 | 39 | members: { create: { userId: user.id, role: "ADMIN" } }, | 40 | 40 | }, | | 41 | + include: { members: true }, | 41 | 42 | }); | | 43 | + const orgMember = organization.members[0]; | 42 | 44 |
| 43 | 45 | const project = await prisma.project.create({ | 44 | 46 | data: { | @@ -60,6 +62,7 @@ export async function seedTestUserProject( | 60 | 62 | shortcode: suffix.slice(0, 4), | 61 | 63 | projectId: project.id, | 62 | 64 | organizationId: organization.id, | | 65 | + orgMemberId: orgMember.id, | 63 | 66 | }, | 64 | 67 | }); | 65 | 68 |
| |
Collapse file Copy file name to clipboard +70Lines changed: 70 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 | +); |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/test/regenerateApiKeyBranch.e2e.full.test.ts +62-1Lines changed: 62 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -3,7 +3,7 @@ import { getTestServer } from "./helpers/sharedTestServer"; | 3 | 3 | import { seedTestUserProject } from "./helpers/seedTestUserProject"; | 4 | 4 |
| 5 | 5 | describe("regenerate API key branch handling", () => { | 6 | | - it.each(["feature", "", "default"])( | | 6 | + it.each(["feature", "default"])( | 7 | 7 | "rejects a present branch header without rotating the root key (%s)", | 8 | 8 | async (branch) => { | 9 | 9 | const server = getTestServer(); | @@ -53,4 +53,65 @@ describe("regenerate API key branch handling", () => { | 53 | 53 | }); | 54 | 54 | expect(revoked?.expiresAt.getTime()).toBeGreaterThan(Date.now()); | 55 | 55 | }); | | 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 | + }); | 56 | 117 | }); |
|