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:
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
+24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user