Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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(),
},
});
}
5 changes: 4 additions & 1 deletion apps/webapp/app/routes/api.v1.auth.jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
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