Files
triggerdotdev--trigger.dev/apps/webapp/app/models/api-key.server.ts
Chris Arderne 088f68b373 feat(webapp): share rate limit bucket across additional API keys per environment (#4508)
## What

Rate-limit the API by **environment** rather than per API key.

Previously the limiter keyed its bucket on the hash of the full
`Authorization` header — one bucket per key. With additional environment
API keys (`tr_*_sk_*`), an environment can mint many keys and each got
its own full bucket, so more keys = higher effective rate limit. This
collapses all of an environment's keys onto a single shared
per-environment bucket, so the ceiling is exactly the configured limit
regardless of key mix.

## How

- `authorizationRateLimitMiddleware` now lets the override return `{
config?, identifier? }`. `identifier`, when present, is the rate limit
bucket key; otherwise it falls back to the hashed `Authorization` header
(unchanged legacy behavior, still used by `engineRateLimiter` and any
unauthenticated fallthrough).
- `apiRateLimiter`'s override resolves the environment id and uses it as
the identifier:
- **Additional keys** (`isAdditionalApiKey`) resolve via a new
`resolveAdditionalApiKeyRateLimitScope()` — a **scope-agnostic** keyHash
→ (environmentId, org limiter config) lookup. It is deliberately
permissive (restricted keys resolve too) because it's used **only for
bucketing, never as an auth decision** — request auth still goes through
the RBAC bearer controller, which enforces scopes. Revoked/expired keys
are excluded so they can't hold a bucket warm.
- **Root/legacy keys** reuse the environment already resolved by
`authenticateAuthorizationHeader` and key on `environment.id` too.
- The identifier is always the stable environment id, never the secret
key (which can rotate and would split the bucket).
- The whole override result is cached per key by the existing SWR cache,
so **no extra per-request lookup and no separate Redis mapping** is
added.

## Behavior notes

- Root + additional keys of the same environment now share one bucket
(ceiling = configured limit, not a multiple of it). Restricted
additional keys are included — they were the biggest gap, since they
authenticate via the RBAC controller and previously fell back to per-key
buckets.
- **Public JWTs** keep their existing fixed-window, per-token bucketing.
- One-time bucket reset on deploy (bucket keys change); harmless.

## Tests

- New: two tokens resolving to the same identifier share one bucket.
- New: with no identifier, bucketing stays per-key (legacy behavior
preserved).
- Updated existing override tests to the new `{ config }` return shape.

Base: `feat/multi-keys-surface`. Closes TRI-12888.
2026-08-06 16:05:27 +01:00

293 lines
8.2 KiB
TypeScript

import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database";
import type { HostRbacController } from "@trigger.dev/rbac";
import { customAlphabet } from "nanoid";
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
import { prisma } from "~/db.server";
import { RuntimeEnvironmentType } from "~/database-types";
import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server";
import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
import { rbac } from "~/services/rbac.server";
import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
const apiKeyId = customAlphabet(
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
12
);
const REVOKED_API_KEY_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000;
type RegenerateAPIKeyInput = {
userId: string;
environmentId: string;
};
export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIKeyInput) {
const environment = await prisma.runtimeEnvironment.findUnique({
where: {
id: environmentId,
},
include: {
organization: true,
project: true,
},
});
if (!environment) {
throw new Error("Environment does not exist");
}
// check if the user is part of the org
const organization = await prisma.organization.findFirst({
where: {
id: environment.organization.id,
members: { some: { userId } },
},
});
if (!organization) {
throw new Error("User does not have permission to regenerate API key");
}
// check if it is the user's dev environment
if (environment.type === RuntimeEnvironmentType.DEVELOPMENT) {
if (!environment.orgMemberId) {
throw new Error("User does not have permission to regenerate API key");
}
const orgMember = await prisma.orgMember.findFirst({
where: {
organizationId: organization.id,
userId: userId,
id: environment.orgMemberId,
},
});
if (!orgMember) {
throw new Error("User does not have permission to regenerate API key");
}
}
// generate and store new keys
const newApiKey = createApiKeyForEnv(environment.type);
const newPkApiKey = createPkApiKeyForEnv(environment.type);
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,
},
});
});
// The env's apiKey changed in the control-plane; drop any cached copy.
controlPlaneResolver.invalidateEnvironment(environmentId);
return updatedEnviroment;
}
export async function createEnvironmentApiKey(
{
environmentId,
taskEnvironmentId,
userId,
name,
expiresAt,
presetId,
taskIdentifiers,
}: {
environmentId: string;
taskEnvironmentId: string;
userId: string;
name: string;
expiresAt?: Date;
presetId: string;
taskIdentifiers?: string[];
},
{
prismaClient = prisma,
rbacController = rbac,
issuanceAllowed,
telemetryRecorder = apiKeyTelemetry,
}: {
prismaClient?: Pick<
PrismaClient,
"apiKey" | "featureFlag" | "organization" | "runtimeEnvironment" | "taskIdentifier"
>;
rbacController?: Pick<HostRbacController, "prepareApiKeyPolicy">;
issuanceAllowed?: (organizationId: string) => Promise<boolean>;
telemetryRecorder?: ApiKeyTelemetry;
} = {}
) {
const environment = await prismaClient.runtimeEnvironment.findFirst({
where: {
id: environmentId,
organization: { members: { some: { userId } } },
},
select: { id: true, type: true, organizationId: true },
});
if (!environment) {
throw new Error("Environment not found");
}
const canIssue =
issuanceAllowed ??
((organizationId) => canIssueAdditionalApiKeys(organizationId, prismaClient));
if (!(await canIssue(environment.organizationId))) {
throw new Error("Creating additional API keys is not enabled.");
}
if (expiresAt && expiresAt.getTime() <= Date.now()) {
throw new Error("Expiration must be in the future");
}
const selectedTasks = [...new Set(taskIdentifiers?.map((task) => task.trim()).filter(Boolean))];
if (selectedTasks.length > MAX_API_KEY_TASK_IDENTIFIERS) {
throw new Error(`You can select at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks for an API key`);
}
if (selectedTasks.length > 0) {
const matchingTasks = await prismaClient.taskIdentifier.count({
where: {
runtimeEnvironmentId: taskEnvironmentId,
slug: { in: selectedTasks },
runtimeEnvironment: {
OR: [{ id: environment.id }, { parentEnvironmentId: environment.id }],
},
},
});
if (matchingTasks !== selectedTasks.length) {
throw new Error("One or more selected tasks are not available in this environment");
}
}
let prepared: Awaited<ReturnType<typeof rbacController.prepareApiKeyPolicy>>;
try {
prepared = await rbacController.prepareApiKeyPolicy({
organizationId: environment.organizationId,
presetId,
taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined,
});
} catch (error) {
telemetryRecorder.recordOperation("prepare_policy", "error", "policy_error");
throw error;
}
if (!prepared.ok) {
telemetryRecorder.recordOperation("prepare_policy", "rejected", "policy_rejected");
throw new Error(prepared.error);
}
telemetryRecorder.recordOperation("prepare_policy", "success");
const generated = generateAdditionalApiKey(environment.type);
const apiKey = await (async () => {
try {
return await prismaClient.apiKey.create({
data: {
name,
keyHash: generated.keyHash,
lastFour: generated.lastFour,
runtimeEnvironmentId: environment.id,
createdByUserId: userId,
expiresAt,
presetId: prepared.policy.presetId,
scopes: prepared.policy.scopes,
},
});
} catch (error) {
telemetryRecorder.recordOperation("create", "error", "database_error");
throw error;
}
})();
telemetryRecorder.recordOperation("create", "success");
return { apiKey, plaintext: generated.apiKey };
}
export async function revokeEnvironmentApiKey(
{
environmentId,
apiKeyId,
}: {
environmentId: string;
apiKeyId: string;
},
{
prismaClient = prisma,
telemetryRecorder = apiKeyTelemetry,
}: {
prismaClient?: Pick<PrismaClient, "apiKey">;
telemetryRecorder?: ApiKeyTelemetry;
} = {}
) {
const result = await (async () => {
try {
return await prismaClient.apiKey.updateMany({
where: {
id: apiKeyId,
runtimeEnvironmentId: environmentId,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
} catch (error) {
telemetryRecorder.recordOperation("revoke", "error", "database_error");
throw error;
}
})();
if (result.count !== 1) {
telemetryRecorder.recordOperation("revoke", "rejected", "not_found_or_revoked");
throw new Error("API key not found or already revoked");
}
telemetryRecorder.recordOperation("revoke", "success");
}
export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
return generateRootApiKey(envType).apiKey;
}
export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
return `pk_${envSlug(envType)}_${apiKeyId(20)}`;
}
export type EnvSlug = "dev" | "stg" | "prod" | "preview";
export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug {
switch (environmentType) {
case "DEVELOPMENT": {
return "dev";
}
case "PRODUCTION": {
return "prod";
}
case "STAGING": {
return "stg";
}
case "PREVIEW": {
return "preview";
}
}
}
export function isEnvSlug(maybeSlug: string): maybeSlug is EnvSlug {
return ["dev", "stg", "prod", "preview"].includes(maybeSlug);
}