Skip to content

Commit 088f68b

Browse files
authored
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.
1 parent 9409ddf commit 088f68b

9 files changed

Lines changed: 324 additions & 63 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
API rate limits now apply per environment, so creating extra API keys no longer increases how many requests an environment can make.

apps/webapp/app/models/api-key.server.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database";
22
import type { HostRbacController } from "@trigger.dev/rbac";
3-
import { trail } from "agentcrumbs"; // @crumbs
43
import { customAlphabet } from "nanoid";
54
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
65
import { prisma } from "~/db.server";
@@ -11,8 +10,6 @@ import { rbac } from "~/services/rbac.server";
1110
import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys";
1211
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
1312

14-
const crumb = trail("webapp"); // @crumbs
15-
1613
const apiKeyId = customAlphabet(
1714
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
1815
12
@@ -220,12 +217,6 @@ export async function createEnvironmentApiKey(
220217
})();
221218
telemetryRecorder.recordOperation("create", "success");
222219

223-
crumb("environment API key created", {
224-
apiKeyId: apiKey.id,
225-
environmentId,
226-
presetId: apiKey.presetId,
227-
}); // @crumbs
228-
229220
return { apiKey, plaintext: generated.apiKey };
230221
}
231222

@@ -267,7 +258,6 @@ export async function revokeEnvironmentApiKey(
267258
}
268259

269260
telemetryRecorder.recordOperation("revoke", "success");
270-
crumb("environment API key revoked", { apiKeyId, environmentId }); // @crumbs
271261
}
272262

273263
export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {

apps/webapp/app/models/runtimeEnvironment.server.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,89 @@ export async function findEnvironmentByApiKeyWithResolution(
301301
return resolveEnvironmentByApiKey(apiKey, branchName, tx, additionalApiKeyLookupEnabled);
302302
}
303303

304+
export type PrivateApiKeyRateLimitScope = {
305+
environmentId: string;
306+
apiRateLimiterConfig: unknown;
307+
};
308+
309+
export async function resolvePrivateApiKeyRateLimitScope(
310+
apiKey: string,
311+
tx: PrismaClientOrTransaction = $replica
312+
): Promise<PrivateApiKeyRateLimitScope | null> {
313+
const now = new Date();
314+
315+
if (isAdditionalApiKey(apiKey)) {
316+
const match = await tx.apiKey.findFirst({
317+
where: {
318+
keyHash: hashApiKey(apiKey),
319+
revokedAt: null,
320+
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
321+
},
322+
select: {
323+
runtimeEnvironment: {
324+
select: {
325+
id: true,
326+
project: { select: { deletedAt: true } },
327+
organization: { select: { apiRateLimiterConfig: true } },
328+
},
329+
},
330+
},
331+
});
332+
333+
if (!match?.runtimeEnvironment || match.runtimeEnvironment.project.deletedAt) {
334+
return null;
335+
}
336+
337+
return {
338+
environmentId: match.runtimeEnvironment.id,
339+
apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig,
340+
};
341+
}
342+
343+
const environment = await tx.runtimeEnvironment.findFirst({
344+
where: { apiKey },
345+
select: {
346+
id: true,
347+
project: { select: { deletedAt: true } },
348+
organization: { select: { apiRateLimiterConfig: true } },
349+
},
350+
});
351+
352+
if (environment) {
353+
if (environment.project.deletedAt) {
354+
return null;
355+
}
356+
357+
return {
358+
environmentId: environment.id,
359+
apiRateLimiterConfig: environment.organization.apiRateLimiterConfig,
360+
};
361+
}
362+
363+
const revokedApiKey = await tx.revokedApiKey.findFirst({
364+
where: { apiKey, expiresAt: { gt: now } },
365+
select: {
366+
runtimeEnvironment: {
367+
select: {
368+
id: true,
369+
project: { select: { deletedAt: true } },
370+
organization: { select: { apiRateLimiterConfig: true } },
371+
},
372+
},
373+
},
374+
});
375+
376+
const revokedEnvironment = revokedApiKey?.runtimeEnvironment;
377+
if (!revokedEnvironment || revokedEnvironment.project.deletedAt) {
378+
return null;
379+
}
380+
381+
return {
382+
environmentId: revokedEnvironment.id,
383+
apiRateLimiterConfig: revokedEnvironment.organization.apiRateLimiterConfig,
384+
};
385+
}
386+
304387
/**
305388
* @deprecated We don't use public API keys (`pk_*` tokens) anymore — public
306389
* access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`).

apps/webapp/app/presenters/v3/LimitsPresenter.server.ts

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Ratelimit } from "@upstash/ratelimit";
22
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
3-
import { createHash } from "node:crypto";
43
import { env } from "~/env.server";
54
import { getCurrentPlan } from "~/services/platform.v3.server";
65
import {
@@ -90,13 +89,11 @@ export class LimitsPresenter extends BasePresenter {
9089
projectId,
9190
environmentId,
9291
environmentType,
93-
environmentApiKey,
9492
}: {
9593
organizationId: string;
9694
projectId: string;
9795
environmentId: string;
9896
environmentType: RuntimeEnvironmentType;
99-
environmentApiKey: string;
10097
}): Promise<LimitsResult> {
10198
// Get organization with all limit-related fields
10299
const organization = await this._replica.organization.findFirstOrThrow({
@@ -168,10 +165,21 @@ export class LimitsPresenter extends BasePresenter {
168165
where: { organizationId },
169166
});
170167

171-
// Get current rate limit tokens for this environment's API key
168+
const runtimeEnv = await this._replica.runtimeEnvironment.findFirst({
169+
where: { id: environmentId },
170+
select: {
171+
id: true,
172+
parentEnvironmentId: true,
173+
maximumConcurrencyLimit: true,
174+
concurrencyLimitBurstFactor: true,
175+
},
176+
});
177+
const apiRateLimitEnvironmentId = runtimeEnv?.parentEnvironmentId ?? environmentId;
178+
179+
// Get current rate limit tokens for this environment's API bucket
172180
const apiRateLimitTokens = await getRateLimitRemainingTokens(
173181
"api",
174-
environmentApiKey,
182+
apiRateLimitEnvironmentId,
175183
apiRateLimitConfig
176184
);
177185
// Batch rate limiter uses environment ID directly (not hashed) with a different key prefix
@@ -181,15 +189,6 @@ export class LimitsPresenter extends BasePresenter {
181189
);
182190

183191
// Get current queue size for this environment
184-
// We need the runtime environment fields for the engine query
185-
const runtimeEnv = await this._replica.runtimeEnvironment.findFirst({
186-
where: { id: environmentId },
187-
select: {
188-
id: true,
189-
maximumConcurrencyLimit: true,
190-
concurrencyLimitBurstFactor: true,
191-
},
192-
});
193192

194193
let currentQueueSize = 0;
195194
if (runtimeEnv) {
@@ -454,20 +453,14 @@ function resolveBatchConcurrencyConfig(batchConcurrencyConfig?: unknown): {
454453

455454
/**
456455
* Query the current remaining tokens for a rate limiter using the Upstash getRemaining method.
457-
* This uses the same configuration and hashing logic as the rate limit middleware.
456+
* The API limiter uses the environment ID as the bucket identifier for private API keys.
458457
*/
459458
async function getRateLimitRemainingTokens(
460459
keyPrefix: string,
461-
apiKey: string,
460+
identifier: string,
462461
config: RateLimiterConfig
463462
): Promise<number | null> {
464463
try {
465-
// Hash the authorization header the same way the rate limiter does
466-
const authorizationValue = `Bearer ${apiKey}`;
467-
const hash = createHash("sha256");
468-
hash.update(authorizationValue);
469-
const hashedKey = hash.digest("hex");
470-
471464
// Create a Ratelimit instance with the same configuration
472465
const limiter = createLimiterFromConfig(config);
473466
const ratelimit = new Ratelimit({
@@ -478,9 +471,9 @@ async function getRateLimitRemainingTokens(
478471
prefix: `ratelimit:${keyPrefix}`,
479472
});
480473

481-
// Use the getRemaining method to get the current remaining tokens
474+
// Use the same identifier as the API rate-limit middleware.
482475
// getRemaining returns a Promise<number>
483-
const remaining = await ratelimit.getRemaining(hashedKey);
476+
const remaining = await ratelimit.getRemaining(identifier);
484477
return remaining;
485478
} catch (error) {
486479
logger.warn("Failed to get rate limit remaining tokens", {

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
7878
projectId: project.id,
7979
environmentId: environment.id,
8080
environmentType: environment.type,
81-
environmentApiKey: environment.apiKey,
8281
})
8382
);
8483

apps/webapp/app/services/apiRateLimit.server.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { tryCatch } from "@trigger.dev/core/v3";
22
import { env } from "~/env.server";
3+
import { resolvePrivateApiKeyRateLimitScope } from "~/models/runtimeEnvironment.server";
34
import { batchStreamGrants } from "~/runEngine/concerns/batchStreamGrantsInstance.server";
45
import { authenticateAuthorizationHeader } from "./apiAuth.server";
56
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
@@ -29,6 +30,21 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
2930
maxItems: 1000,
3031
},
3132
limiterConfigOverride: async (authorizationValue) => {
33+
const rawApiKey = authorizationValue.replace(/^Bearer /, "");
34+
35+
if (rawApiKey.startsWith("tr_")) {
36+
const scope = await resolvePrivateApiKeyRateLimitScope(rawApiKey);
37+
38+
if (!scope) {
39+
return;
40+
}
41+
42+
return {
43+
config: scope.apiRateLimiterConfig,
44+
identifier: scope.environmentId,
45+
};
46+
}
47+
3248
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
3349
allowPublicKey: true,
3450
allowJWT: true,
@@ -40,13 +56,19 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
4056

4157
if (authenticatedEnv.type === "PUBLIC_JWT") {
4258
return {
43-
type: "fixedWindow",
44-
window: env.API_RATE_LIMIT_JWT_WINDOW,
45-
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
59+
config: {
60+
type: "fixedWindow",
61+
window: env.API_RATE_LIMIT_JWT_WINDOW,
62+
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
63+
},
4664
};
47-
} else {
48-
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
4965
}
66+
67+
return {
68+
config: authenticatedEnv.environment.organization.apiRateLimiterConfig,
69+
// Public keys are browser-distributed, so keep them on per-key buckets.
70+
identifier: authenticatedEnv.type === "PRIVATE" ? authenticatedEnv.environment.id : undefined,
71+
};
5072
},
5173
pathMatchers: [/^\/api/],
5274
// Allow /api/v1/tasks/:id/callback/:secret

0 commit comments

Comments
 (0)