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/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", }); 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