From d21217d30ec988cf45dcf08f677a6106293aee60 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 20 Apr 2026 17:40:07 +0100 Subject: [PATCH 1/2] 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 --- .../revoked-api-key-grace-period.md | 6 +++ .../environments/RegenerateApiKeyModal.tsx | 5 +- apps/webapp/app/models/api-key.server.ts | 30 ++++++++--- .../app/models/runtimeEnvironment.server.ts | 53 +++++++++++++------ ...pi.v1.revoked-api-keys.$revokedApiKeyId.ts | 48 +++++++++++++++++ .../migration.sql | 24 +++++++++ .../database/prisma/schema.prisma | 15 ++++++ 7 files changed, 155 insertions(+), 26 deletions(-) create mode 100644 .server-changes/revoked-api-key-grace-period.md create mode 100644 apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts create mode 100644 internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql diff --git a/.server-changes/revoked-api-key-grace-period.md b/.server-changes/revoked-api-key-grace-period.md new file mode 100644 index 00000000000..df8727295ea --- /dev/null +++ b/.server-changes/revoked-api-key-grace-period.md @@ -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. diff --git a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx index 439fd892f91..52e1f499cbe 100644 --- a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx +++ b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx @@ -75,8 +75,9 @@ const RegenerateApiKeyModalContent = ({ return (
- {`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.`} { + 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; diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index f65112b71fc..c919fe4a618 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -11,27 +11,48 @@ export async function findEnvironmentByApiKey( apiKey: string, branchName: string | undefined ): Promise { - 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 { diff --git a/apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts b/apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts new file mode 100644 index 00000000000..847828d981f --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.revoked-api-keys.$revokedApiKeyId.ts @@ -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(), + }, + }); +} diff --git a/internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql b/internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql new file mode 100644 index 00000000000..f3a2ffa199f --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260420000000_add_revoked_api_key_table/migration.sql @@ -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; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 1462752d8dd..9ccf2495d3a 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -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 From 2a63995b0eb08b56895d8aea781a1eba4e683f22 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 20 Apr 2026 18:10:36 +0100 Subject: [PATCH 2/2] fix(webapp): sign public JWTs with env.apiKey, not the raw header key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During the grace window after an API key rotation, a client calling /api/v1/auth/jwt with their old (revoked but still valid) key would get back a JWT that immediately failed validation, because signing used the header key (old) while validation in jwtAuth.server.ts uses environment.apiKey (new). Sign with the environment's canonical current key instead so minted JWTs validate regardless of which accepted key was used to authenticate the mint call. Works for dev/prod/preview — in the PREVIEW branch path, findEnvironmentByApiKey already merges the parent's current apiKey onto the returned env. --- apps/webapp/app/routes/api.v1.auth.jwt.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/api.v1.auth.jwt.ts b/apps/webapp/app/routes/api.v1.auth.jwt.ts index e495c9b3688..b95b1eb7877 100644 --- a/apps/webapp/app/routes/api.v1.auth.jwt.ts +++ b/apps/webapp/app/routes/api.v1.auth.jwt.ts @@ -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", });