03e4d5fe31
## 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
355 lines
7.4 KiB
TypeScript
355 lines
7.4 KiB
TypeScript
import type { AuthenticatedEnvironment } from "@internal/run-engine";
|
|
import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@trigger.dev/database";
|
|
import { $replica, prisma } from "~/db.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { getUsername } from "~/utils/username";
|
|
import { sanitizeBranchName } from "~/v3/gitBranch";
|
|
|
|
export type { RuntimeEnvironment };
|
|
|
|
export async function findEnvironmentByApiKey(
|
|
apiKey: string,
|
|
branchName: string | undefined
|
|
): Promise<AuthenticatedEnvironment | null> {
|
|
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,
|
|
});
|
|
|
|
// 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) {
|
|
return null;
|
|
}
|
|
|
|
if (environment.type === "PREVIEW") {
|
|
if (!branchName) {
|
|
logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", {
|
|
environmentId: environment.id,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const childEnvironment = environment.childEnvironments.at(0);
|
|
|
|
if (childEnvironment) {
|
|
return {
|
|
...childEnvironment,
|
|
apiKey: environment.apiKey,
|
|
orgMember: environment.orgMember,
|
|
organization: environment.organization,
|
|
project: environment.project,
|
|
};
|
|
}
|
|
|
|
//A branch was specified but no child environment was found
|
|
return null;
|
|
}
|
|
|
|
return environment;
|
|
}
|
|
|
|
/** @deprecated We don't use public api keys anymore */
|
|
export async function findEnvironmentByPublicApiKey(
|
|
apiKey: string,
|
|
branchName: string | undefined
|
|
): Promise<AuthenticatedEnvironment | null> {
|
|
const environment = await $replica.runtimeEnvironment.findFirst({
|
|
where: {
|
|
pkApiKey: apiKey,
|
|
},
|
|
include: {
|
|
project: true,
|
|
organization: true,
|
|
orgMember: true,
|
|
},
|
|
});
|
|
|
|
//don't return deleted projects
|
|
if (environment?.project.deletedAt !== null) {
|
|
return null;
|
|
}
|
|
|
|
return environment;
|
|
}
|
|
|
|
export async function findEnvironmentById(
|
|
id: string
|
|
): Promise<(AuthenticatedEnvironment & { parentEnvironment: { apiKey: string } | null }) | null> {
|
|
const environment = await $replica.runtimeEnvironment.findFirst({
|
|
where: {
|
|
id,
|
|
},
|
|
include: {
|
|
project: true,
|
|
organization: true,
|
|
orgMember: true,
|
|
parentEnvironment: {
|
|
select: {
|
|
apiKey: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
//don't return deleted projects
|
|
if (environment?.project.deletedAt !== null) {
|
|
return null;
|
|
}
|
|
|
|
return environment;
|
|
}
|
|
|
|
export async function findEnvironmentBySlug(
|
|
projectId: string,
|
|
envSlug: string,
|
|
userId: string
|
|
): Promise<AuthenticatedEnvironment | null> {
|
|
return $replica.runtimeEnvironment.findFirst({
|
|
where: {
|
|
projectId: projectId,
|
|
slug: envSlug,
|
|
OR: [
|
|
{
|
|
type: {
|
|
in: ["PREVIEW", "STAGING", "PRODUCTION"],
|
|
},
|
|
},
|
|
{
|
|
type: "DEVELOPMENT",
|
|
orgMember: {
|
|
userId,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
include: {
|
|
project: true,
|
|
organization: true,
|
|
orgMember: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function findEnvironmentFromRun(
|
|
runId: string,
|
|
tx?: PrismaClientOrTransaction
|
|
): Promise<AuthenticatedEnvironment | null> {
|
|
const taskRun = await (tx ?? $replica).taskRun.findFirst({
|
|
where: {
|
|
id: runId,
|
|
},
|
|
include: {
|
|
runtimeEnvironment: {
|
|
include: {
|
|
project: true,
|
|
organization: true,
|
|
orgMember: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!taskRun) {
|
|
return null;
|
|
}
|
|
|
|
return taskRun?.runtimeEnvironment;
|
|
}
|
|
|
|
export async function createNewSession(environment: RuntimeEnvironment, ipAddress: string) {
|
|
const session = await prisma.runtimeEnvironmentSession.create({
|
|
data: {
|
|
environmentId: environment.id,
|
|
ipAddress,
|
|
},
|
|
});
|
|
|
|
await prisma.runtimeEnvironment.update({
|
|
where: {
|
|
id: environment.id,
|
|
},
|
|
data: {
|
|
currentSessionId: session.id,
|
|
},
|
|
});
|
|
|
|
return session;
|
|
}
|
|
|
|
export async function disconnectSession(environmentId: string) {
|
|
const environment = await prisma.runtimeEnvironment.findFirst({
|
|
where: {
|
|
id: environmentId,
|
|
},
|
|
});
|
|
|
|
if (!environment || !environment.currentSessionId) {
|
|
return null;
|
|
}
|
|
|
|
const session = await prisma.runtimeEnvironmentSession.update({
|
|
where: {
|
|
id: environment.currentSessionId,
|
|
},
|
|
data: {
|
|
disconnectedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
await prisma.runtimeEnvironment.update({
|
|
where: {
|
|
id: environment.id,
|
|
},
|
|
data: {
|
|
currentSessionId: null,
|
|
},
|
|
});
|
|
|
|
return session;
|
|
}
|
|
|
|
export async function findLatestSession(environmentId: string) {
|
|
const session = await $replica.runtimeEnvironmentSession.findFirst({
|
|
where: {
|
|
environmentId,
|
|
},
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
});
|
|
|
|
return session;
|
|
}
|
|
|
|
export type DisplayableInputEnvironment = Prisma.RuntimeEnvironmentGetPayload<{
|
|
select: {
|
|
id: true;
|
|
type: true;
|
|
slug: true;
|
|
orgMember: {
|
|
select: {
|
|
user: {
|
|
select: {
|
|
id: true;
|
|
name: true;
|
|
displayName: true;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}>;
|
|
|
|
export function displayableEnvironment(
|
|
environment: DisplayableInputEnvironment,
|
|
userId: string | undefined
|
|
) {
|
|
let userName: string | undefined = undefined;
|
|
|
|
if (environment.type === "DEVELOPMENT") {
|
|
if (!environment.orgMember) {
|
|
userName = "Deleted";
|
|
} else if (environment.orgMember.user.id !== userId) {
|
|
userName = getUsername(environment.orgMember.user);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: environment.id,
|
|
type: environment.type,
|
|
slug: environment.slug,
|
|
userName,
|
|
};
|
|
}
|
|
|
|
export async function findDisplayableEnvironment(
|
|
environmentId: string,
|
|
userId: string | undefined
|
|
) {
|
|
const environment = await $replica.runtimeEnvironment.findFirst({
|
|
where: {
|
|
id: environmentId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
type: true,
|
|
slug: true,
|
|
orgMember: {
|
|
select: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
displayName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!environment) {
|
|
return;
|
|
}
|
|
|
|
return displayableEnvironment(environment, userId);
|
|
}
|
|
|
|
export async function hasAccessToEnvironment({
|
|
environmentId,
|
|
projectId,
|
|
organizationId,
|
|
userId,
|
|
}: {
|
|
environmentId: string;
|
|
projectId: string;
|
|
organizationId: string;
|
|
userId: string;
|
|
}): Promise<boolean> {
|
|
const environment = await $replica.runtimeEnvironment.findFirst({
|
|
where: {
|
|
id: environmentId,
|
|
projectId: projectId,
|
|
organizationId: organizationId,
|
|
organization: { members: { some: { userId } } },
|
|
},
|
|
});
|
|
|
|
return environment !== null;
|
|
}
|