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
This commit is contained in:
Eric Allam
2026-04-20 18:28:16 +01:00
committed by GitHub
parent de3b9a158b
commit 03e4d5fe31
8 changed files with 159 additions and 27 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
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.
@@ -75,8 +75,9 @@ const RegenerateApiKeyModalContent = ({
return (
<div className="flex flex-col items-center gap-y-4 pt-4">
<Callout variant="warning">
{`Regenerating the keys for this environment will temporarily break any live tasks in the
${title} environment until the new API keys are set in the relevant environment variables.`}
{`A new API key will be issued for the ${title} environment. The previous key stays valid
for 24 hours so you can roll out the new key in your environment variables without downtime.
After 24 hours, the previous key stops working.`}
</Callout>
<fetcher.Form
method="post"
+22 -8
View File
@@ -8,6 +8,8 @@ const apiKeyId = customAlphabet(
12
);
const REVOKED_API_KEY_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000;
type RegenerateAPIKeyInput = {
userId: string;
environmentId: string;
@@ -63,14 +65,26 @@ export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIK
const newApiKey = createApiKeyForEnv(environment.type);
const newPkApiKey = createPkApiKeyForEnv(environment.type);
const updatedEnviroment = await prisma.runtimeEnvironment.update({
data: {
apiKey: newApiKey,
pkApiKey: newPkApiKey,
},
where: {
id: environmentId,
},
const revokedApiKeyExpiresAt = new Date(Date.now() + REVOKED_API_KEY_GRACE_PERIOD_MS);
const updatedEnviroment = await prisma.$transaction(async (tx) => {
await tx.revokedApiKey.create({
data: {
apiKey: environment.apiKey,
runtimeEnvironmentId: environment.id,
expiresAt: revokedApiKeyExpiresAt,
},
});
return tx.runtimeEnvironment.update({
data: {
apiKey: newApiKey,
pkApiKey: newPkApiKey,
},
where: {
id: environmentId,
},
});
});
return updatedEnviroment;
@@ -11,27 +11,48 @@ export async function findEnvironmentByApiKey(
apiKey: string,
branchName: string | undefined
): Promise<AuthenticatedEnvironment | null> {
const environment = await $replica.runtimeEnvironment.findFirst({
const include = {
project: true,
organization: true,
orgMember: true,
childEnvironments: branchName
? {
where: {
branchName: sanitizeBranchName(branchName),
archivedAt: null,
},
}
: undefined,
} satisfies Prisma.RuntimeEnvironmentInclude;
let environment = await $replica.runtimeEnvironment.findFirst({
where: {
apiKey,
},
include: {
project: true,
organization: true,
orgMember: true,
childEnvironments: branchName
? {
where: {
branchName: sanitizeBranchName(branchName),
archivedAt: null,
},
}
: undefined,
},
include,
});
// Fall back to keys that were revoked within the grace window
if (!environment) {
const revokedApiKey = await $replica.revokedApiKey.findFirst({
where: {
apiKey,
expiresAt: { gt: new Date() },
},
include: {
runtimeEnvironment: { include },
},
});
environment = revokedApiKey?.runtimeEnvironment ?? null;
}
if (!environment) {
return null;
}
//don't return deleted projects
if (environment?.project.deletedAt !== null) {
if (environment.project.deletedAt !== null) {
return null;
}
@@ -43,7 +64,7 @@ export async function findEnvironmentByApiKey(
return null;
}
const childEnvironment = environment?.childEnvironments.at(0);
const childEnvironment = environment.childEnvironments.at(0);
if (childEnvironment) {
return {
@@ -0,0 +1,48 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
const ParamsSchema = z.object({
revokedApiKeyId: z.string(),
});
const RequestBodySchema = z.object({
expiresAt: z.coerce.date(),
});
export async function action({ request, params }: ActionFunctionArgs) {
await requireAdminApiRequest(request);
const { revokedApiKeyId } = ParamsSchema.parse(params);
const rawBody = await request.json();
const parsedBody = RequestBodySchema.safeParse(rawBody);
if (!parsedBody.success) {
return json({ error: "Invalid request body", issues: parsedBody.error.issues }, { status: 400 });
}
const existing = await prisma.revokedApiKey.findFirst({
where: { id: revokedApiKeyId },
select: { id: true },
});
if (!existing) {
return json({ error: "Revoked API key not found" }, { status: 404 });
}
const updated = await prisma.revokedApiKey.update({
where: { id: revokedApiKeyId },
data: { expiresAt: parsedBody.data.expiresAt },
});
return json({
success: true,
revokedApiKey: {
id: updated.id,
runtimeEnvironmentId: updated.runtimeEnvironmentId,
expiresAt: updated.expiresAt.toISOString(),
},
});
}
+4 -1
View File
@@ -36,8 +36,11 @@ export async function action({ request }: LoaderFunctionArgs) {
...parsedBody.data.claims,
};
// Sign with the environment's current canonical key, not the raw header key,
// so JWTs minted with a revoked (grace-window) key still validate — validation
// in jwtAuth.server.ts uses environment.apiKey.
const jwt = await internal_generateJWT({
secretKey: authenticationResult.apiKey,
secretKey: authenticationResult.environment.apiKey,
payload: claims,
expirationTime: parsedBody.data.expirationTime ?? "1h",
});
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "RevokedApiKey" (
"id" TEXT NOT NULL,
"apiKey" TEXT NOT NULL,
"runtimeEnvironmentId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RevokedApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RevokedApiKey_apiKey_idx"
ON "RevokedApiKey"("apiKey");
-- CreateIndex
CREATE INDEX "RevokedApiKey_runtimeEnvironmentId_idx"
ON "RevokedApiKey"("runtimeEnvironmentId");
-- AddForeignKey
ALTER TABLE "RevokedApiKey"
ADD CONSTRAINT "RevokedApiKey_runtimeEnvironmentId_fkey"
FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "RuntimeEnvironment"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -355,6 +355,7 @@ model RuntimeEnvironment {
prompts Prompt[]
errorGroupStates ErrorGroupState[]
taskIdentifiers TaskIdentifier[]
revokedApiKeys RevokedApiKey[]
@@unique([projectId, slug, orgMemberId])
@@unique([projectId, shortcode])
@@ -363,6 +364,20 @@ model RuntimeEnvironment {
@@index([organizationId])
}
/// Records of previously-valid API keys that are still accepted for authentication
/// during a grace window after rotation. Extend or end the grace period by updating `expiresAt`.
model RevokedApiKey {
id String @id @default(cuid())
apiKey String
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
runtimeEnvironmentId String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([apiKey])
@@index([runtimeEnvironmentId])
}
enum RuntimeEnvironmentType {
PRODUCTION
STAGING