Skip to content

Commit 03e4d5f

Browse files
authored
feat(webapp,database): API key rotation grace period (#3420)
## Summary Regenerating a RuntimeEnvironment API key no longer immediately invalidates the previous one. Rotation is now overlap-based: the old key keeps working for 24 hours so customers can roll it out in their env vars without downtime, then stops working. ## Design - **New `RevokedApiKey` table** (one row per revocation). Holds the archived `apiKey`, a FK to the env, an `expiresAt`, and a `createdAt`. Indexed on `apiKey` (high-cardinality equality — single-row hits) and on `runtimeEnvironmentId`. - **`regenerateApiKey` wraps both writes in a single `$transaction`:** insert a `RevokedApiKey` with `expiresAt = now + 24h`, update the env with the new `apiKey`/`pkApiKey`. - **`findEnvironmentByApiKey` does a two-step lookup:** primary unique-index hit on `RuntimeEnvironment.apiKey` first; on miss, `RevokedApiKey.findFirst({ apiKey, expiresAt: { gt: now } })` with an `include: { runtimeEnvironment }`. Two-step (not `OR`-join) keeps the hot path identical to today and puts the fallback cost only on invalid keys. Both lookups use `$replica`. - **Admin endpoint** `POST /admin/api/v1/revoked-api-keys/:id` accepts `{ expiresAt }` and updates the row. Setting to `now` ends the grace window immediately; setting to the future extends it. - **Modal copy** on the regenerate dialog updated — previously warned of downtime, now explains the 24h overlap. ## Why a separate table instead of columns on `RuntimeEnvironment` - Keeps the hot auth path's primary lookup unchanged — no OR/nullable-apiKey semantics to reason about. - Naturally supports multiple in-flight grace windows (regenerate twice in a day → two old keys valid until their independent expiries). - FK + cascade cleans up correctly when an env is deleted; nothing to backfill. ## Test plan Verified locally against hello-world with dev and prod env keys: - [x] baseline — current key authenticates (`GET /api/v1/runs`) → `200` - [x] regenerate via UI — DB shows old key in `RevokedApiKey` with `expiresAt ≈ now+24h`, env has new key - [x] grace window — both old and new keys → `200`; bogus key → `401` - [x] admin endpoint: `expiresAt = now` → old key `401` - [x] admin endpoint: `expiresAt = +1h` (after early-expire) → old key `200` again - [x] admin endpoint: `expiresAt = past` → old key `401` - [x] admin 400 (invalid body), 404 (unknown id), 401 (missing/non-admin PAT) - [x] same flow exercised end-to-end on a PROD-typed env — behavior identical - [x] `pnpm run typecheck --filter webapp` passes
1 parent de3b9a1 commit 03e4d5f

8 files changed

Lines changed: 159 additions & 27 deletions

File tree

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+
}

apps/webapp/app/routes/api.v1.auth.jwt.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ export async function action({ request }: LoaderFunctionArgs) {
3636
...parsedBody.data.claims,
3737
};
3838

39+
// Sign with the environment's current canonical key, not the raw header key,
40+
// so JWTs minted with a revoked (grace-window) key still validate — validation
41+
// in jwtAuth.server.ts uses environment.apiKey.
3942
const jwt = await internal_generateJWT({
40-
secretKey: authenticationResult.apiKey,
43+
secretKey: authenticationResult.environment.apiKey,
4144
payload: claims,
4245
expirationTime: parsedBody.data.expirationTime ?? "1h",
4346
});
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)