Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/revoked-api-key-grace-period.md
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
Expand Up @@ -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.`}
Comment thread
ericallam marked this conversation as resolved.
</Callout>
<fetcher.Form
method="post"
Expand Down
30 changes: 22 additions & 8 deletions apps/webapp/app/models/api-key.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Comment thread
ericallam marked this conversation as resolved.
Expand Down
53 changes: 37 additions & 16 deletions apps/webapp/app/models/runtimeEnvironment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

if (!environment) {
return null;
}

//don't return deleted projects
if (environment?.project.deletedAt !== null) {
if (environment.project.deletedAt !== null) {
return null;
}

Expand All @@ -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 {
Expand Down
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 });
}
Comment thread
ericallam marked this conversation as resolved.

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;
15 changes: 15 additions & 0 deletions internal-packages/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ model RuntimeEnvironment {
prompts Prompt[]
errorGroupStates ErrorGroupState[]
taskIdentifiers TaskIdentifier[]
revokedApiKeys RevokedApiKey[]

@@unique([projectId, slug, orgMemberId])
@@unique([projectId, shortcode])
Expand All @@ -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])
}
Comment thread
ericallam marked this conversation as resolved.

enum RuntimeEnvironmentType {
PRODUCTION
STAGING
Expand Down
Loading