Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp,database): API key rotation grace period#3420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
ericallam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
devin-ai-integration[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| 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 { | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); | ||
| } | ||
ericallam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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(), | ||
| }, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]) | ||
| } | ||
ericallam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| enum RuntimeEnvironmentType { | ||
| PRODUCTION | ||
| STAGING | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.