Skip to content

Commit d21217d

Browse files
committed
feat(webapp,database): API key rotation grace period
Regenerating a RuntimeEnvironment API key no longer immediately invalidates the previous key. The old key is archived in a new `RevokedApiKey` table with a 24-hour expiresAt. `findEnvironmentByApiKey` falls back to this table when the primary lookup misses. An admin endpoint (`POST /admin/api/v1/revoked-api-keys/:id`) lets us shorten or extend the grace window by updating `expiresAt`. - schema: new RevokedApiKey model, indexes on apiKey and runtimeEnvironmentId - regenerateApiKey: wraps archive + update in a single transaction - modal copy updated to describe the 24h overlap instead of downtime
1 parent 7d7ebdd commit d21217d

File tree

7 files changed

+155
-26
lines changed

7 files changed

+155
-26
lines changed
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+
Regenerating a RuntimeEnvironment API key no longer invalidates the previous key immediately. The old key is recorded in a new `RevokedApiKey` table with a 24 hour grace window, and `findEnvironmentByApiKey` falls back to it when the submitted key doesn't match any live environment. The grace window can be ended early (or extended) by updating `expiresAt` on the row.

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ const RegenerateApiKeyModalContent = ({
7575
return (
7676
<div className="flex flex-col items-center gap-y-4 pt-4">
7777
<Callout variant="warning">
78-
{`Regenerating the keys for this environment will temporarily break any live tasks in the
79-
${title} environment until the new API keys are set in the relevant environment variables.`}
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.`}
8081
</Callout>
8182
<fetcher.Form
8283
method="post"

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

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const apiKeyId = customAlphabet(
88
12
99
);
1010

11+
const REVOKED_API_KEY_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000;
12+
1113
type RegenerateAPIKeyInput = {
1214
userId: string;
1315
environmentId: string;
@@ -63,14 +65,26 @@ export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIK
6365
const newApiKey = createApiKeyForEnv(environment.type);
6466
const newPkApiKey = createPkApiKeyForEnv(environment.type);
6567

66-
const updatedEnviroment = await prisma.runtimeEnvironment.update({
67-
data: {
68-
apiKey: newApiKey,
69-
pkApiKey: newPkApiKey,
70-
},
71-
where: {
72-
id: environmentId,
73-
},
68+
const revokedApiKeyExpiresAt = new Date(Date.now() + REVOKED_API_KEY_GRACE_PERIOD_MS);
69+
70+
const updatedEnviroment = await prisma.$transaction(async (tx) => {
71+
await tx.revokedApiKey.create({
72+
data: {
73+
apiKey: environment.apiKey,
74+
runtimeEnvironmentId: environment.id,
75+
expiresAt: revokedApiKeyExpiresAt,
76+
},
77+
});
78+
79+
return tx.runtimeEnvironment.update({
80+
data: {
81+
apiKey: newApiKey,
82+
pkApiKey: newPkApiKey,
83+
},
84+
where: {
85+
id: environmentId,
86+
},
87+
});
7488
});
7589

7690
return updatedEnviroment;

apps/webapp/app/models/runtimeEnvironment.server.ts

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,48 @@ export async function findEnvironmentByApiKey(
1111
apiKey: string,
1212
branchName: string | undefined
1313
): Promise<AuthenticatedEnvironment | null> {
14-
const environment = await $replica.runtimeEnvironment.findFirst({
14+
const include = {
15+
project: true,
16+
organization: true,
17+
orgMember: true,
18+
childEnvironments: branchName
19+
? {
20+
where: {
21+
branchName: sanitizeBranchName(branchName),
22+
archivedAt: null,
23+
},
24+
}
25+
: undefined,
26+
} satisfies Prisma.RuntimeEnvironmentInclude;
27+
28+
let environment = await $replica.runtimeEnvironment.findFirst({
1529
where: {
1630
apiKey,
1731
},
18-
include: {
19-
project: true,
20-
organization: true,
21-
orgMember: true,
22-
childEnvironments: branchName
23-
? {
24-
where: {
25-
branchName: sanitizeBranchName(branchName),
26-
archivedAt: null,
27-
},
28-
}
29-
: undefined,
30-
},
32+
include,
3133
});
3234

35+
// Fall back to keys that were revoked within the grace window
36+
if (!environment) {
37+
const revokedApiKey = await $replica.revokedApiKey.findFirst({
38+
where: {
39+
apiKey,
40+
expiresAt: { gt: new Date() },
41+
},
42+
include: {
43+
runtimeEnvironment: { include },
44+
},
45+
});
46+
47+
environment = revokedApiKey?.runtimeEnvironment ?? null;
48+
}
49+
50+
if (!environment) {
51+
return null;
52+
}
53+
3354
//don't return deleted projects
34-
if (environment?.project.deletedAt !== null) {
55+
if (environment.project.deletedAt !== null) {
3556
return null;
3657
}
3758

@@ -43,7 +64,7 @@ export async function findEnvironmentByApiKey(
4364
return null;
4465
}
4566

46-
const childEnvironment = environment?.childEnvironments.at(0);
67+
const childEnvironment = environment.childEnvironments.at(0);
4768

4869
if (childEnvironment) {
4970
return {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { prisma } from "~/db.server";
4+
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
5+
6+
const ParamsSchema = z.object({
7+
revokedApiKeyId: z.string(),
8+
});
9+
10+
const RequestBodySchema = z.object({
11+
expiresAt: z.coerce.date(),
12+
});
13+
14+
export async function action({ request, params }: ActionFunctionArgs) {
15+
await requireAdminApiRequest(request);
16+
17+
const { revokedApiKeyId } = ParamsSchema.parse(params);
18+
19+
const rawBody = await request.json();
20+
const parsedBody = RequestBodySchema.safeParse(rawBody);
21+
22+
if (!parsedBody.success) {
23+
return json({ error: "Invalid request body", issues: parsedBody.error.issues }, { status: 400 });
24+
}
25+
26+
const existing = await prisma.revokedApiKey.findFirst({
27+
where: { id: revokedApiKeyId },
28+
select: { id: true },
29+
});
30+
31+
if (!existing) {
32+
return json({ error: "Revoked API key not found" }, { status: 404 });
33+
}
34+
35+
const updated = await prisma.revokedApiKey.update({
36+
where: { id: revokedApiKeyId },
37+
data: { expiresAt: parsedBody.data.expiresAt },
38+
});
39+
40+
return json({
41+
success: true,
42+
revokedApiKey: {
43+
id: updated.id,
44+
runtimeEnvironmentId: updated.runtimeEnvironmentId,
45+
expiresAt: updated.expiresAt.toISOString(),
46+
},
47+
});
48+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- CreateTable
2+
CREATE TABLE "RevokedApiKey" (
3+
"id" TEXT NOT NULL,
4+
"apiKey" TEXT NOT NULL,
5+
"runtimeEnvironmentId" TEXT NOT NULL,
6+
"expiresAt" TIMESTAMP(3) NOT NULL,
7+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
9+
CONSTRAINT "RevokedApiKey_pkey" PRIMARY KEY ("id")
10+
);
11+
12+
-- CreateIndex
13+
CREATE INDEX "RevokedApiKey_apiKey_idx"
14+
ON "RevokedApiKey"("apiKey");
15+
16+
-- CreateIndex
17+
CREATE INDEX "RevokedApiKey_runtimeEnvironmentId_idx"
18+
ON "RevokedApiKey"("runtimeEnvironmentId");
19+
20+
-- AddForeignKey
21+
ALTER TABLE "RevokedApiKey"
22+
ADD CONSTRAINT "RevokedApiKey_runtimeEnvironmentId_fkey"
23+
FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "RuntimeEnvironment"("id")
24+
ON DELETE CASCADE ON UPDATE CASCADE;

internal-packages/database/prisma/schema.prisma

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ model RuntimeEnvironment {
355355
prompts Prompt[]
356356
errorGroupStates ErrorGroupState[]
357357
taskIdentifiers TaskIdentifier[]
358+
revokedApiKeys RevokedApiKey[]
358359
359360
@@unique([projectId, slug, orgMemberId])
360361
@@unique([projectId, shortcode])
@@ -363,6 +364,20 @@ model RuntimeEnvironment {
363364
@@index([organizationId])
364365
}
365366

367+
/// Records of previously-valid API keys that are still accepted for authentication
368+
/// during a grace window after rotation. Extend or end the grace period by updating `expiresAt`.
369+
model RevokedApiKey {
370+
id String @id @default(cuid())
371+
apiKey String
372+
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
373+
runtimeEnvironmentId String
374+
expiresAt DateTime
375+
createdAt DateTime @default(now())
376+
377+
@@index([apiKey])
378+
@@index([runtimeEnvironmentId])
379+
}
380+
366381
enum RuntimeEnvironmentType {
367382
PRODUCTION
368383
STAGING

0 commit comments

Comments
 (0)