Skip to content

Commit 7112e74

Browse files
committed
fix(webapp): bucket restricted additional API keys by environment too
1 parent 305f4cf commit 7112e74

4 files changed

Lines changed: 114 additions & 41 deletions

File tree

.server-changes/additional-api-key-rate-limit-bucket.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: fix
44
---
55

6-
Additional environment API keys now share a single rate limit bucket per environment instead of each key getting its own, so minting more keys no longer multiplies an environment's effective API rate limit. Root and legacy keys are unaffected. The bucket is keyed on the stable environment id resolved from the already-authenticated request, so no extra lookup is added.
6+
Additional environment API keys now share a single rate limit bucket per environment instead of each key getting its own, so minting more keys — including scope-restricted keys — no longer multiplies an environment's effective API rate limit. Root and legacy keys are unaffected. The bucket is keyed on the stable environment id (resolved scope-agnostically for bucketing only, never as an auth decision), and the result is cached per key so no extra per-request lookup is added.

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

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

304+
export type AdditionalApiKeyRateLimitScope = {
305+
environmentId: string;
306+
// Organization rate limiter override (bucket size), if configured.
307+
apiRateLimiterConfig: unknown;
308+
};
309+
310+
/**
311+
* Resolve ONLY the environment id (and its organization's rate limiter config)
312+
* for an additional API key, for RATE-LIMIT BUCKETING.
313+
*
314+
* Deliberately scope-agnostic: unlike `findEnvironmentByApiKey`, a
315+
* scope-restricted additional key still resolves here, so every additional key
316+
* for an environment shares that environment's rate limit bucket. This is NOT
317+
* an authentication or authorization decision and must never be used as one —
318+
* request auth still goes through the RBAC bearer controller, which enforces
319+
* scopes. Revoked and expired keys are excluded so they cannot keep a bucket
320+
* warm.
321+
*/
322+
export async function resolveAdditionalApiKeyRateLimitScope(
323+
apiKey: string,
324+
tx: PrismaClientOrTransaction = $replica
325+
): Promise<AdditionalApiKeyRateLimitScope | null> {
326+
if (!isAdditionalApiKey(apiKey)) {
327+
return null;
328+
}
329+
330+
const now = new Date();
331+
332+
const match = await tx.apiKey.findFirst({
333+
where: {
334+
keyHash: hashApiKey(apiKey),
335+
revokedAt: null,
336+
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
337+
},
338+
select: {
339+
runtimeEnvironment: {
340+
select: {
341+
id: true,
342+
organization: { select: { apiRateLimiterConfig: true } },
343+
},
344+
},
345+
},
346+
});
347+
348+
if (!match?.runtimeEnvironment) {
349+
return null;
350+
}
351+
352+
return {
353+
environmentId: match.runtimeEnvironment.id,
354+
apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig,
355+
};
356+
}
357+
304358
/**
305359
* @deprecated We don't use public API keys (`pk_*` tokens) anymore — public
306360
* access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`).

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

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { tryCatch } from "@trigger.dev/core/v3";
22
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
33
import { env } from "~/env.server";
4+
import { resolveAdditionalApiKeyRateLimitScope } from "~/models/runtimeEnvironment.server";
45
import { batchStreamGrants } from "~/runEngine/concerns/batchStreamGrantsInstance.server";
56
import { authenticateAuthorizationHeader } from "./apiAuth.server";
67
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
@@ -30,6 +31,34 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
3031
maxItems: 1000,
3132
},
3233
limiterConfigOverride: async (authorizationValue) => {
34+
const rawApiKey = authorizationValue.replace(/^Bearer /, "");
35+
36+
// Additional API keys (`tr_*_sk_*`) share their environment's rate limit
37+
// bucket rather than each getting their own. Keying on the stable
38+
// environment id (not the secret key, which can rotate) keeps a single
39+
// bucket per environment no matter how many additional keys exist.
40+
//
41+
// Resolve scope-agnostically for bucketing: a restricted additional key
42+
// authenticates at the route level via the RBAC controller (and fails
43+
// closed in the legacy header auth below), but for rate limiting it must
44+
// still land on its environment's shared bucket — otherwise minting many
45+
// restricted keys would multiply the effective limit. This is NOT an auth
46+
// decision. The whole override result is cached per key by the
47+
// middleware's SWR cache, so no separate lookup or Redis mapping is needed.
48+
if (isAdditionalApiKey(rawApiKey)) {
49+
const scope = await resolveAdditionalApiKeyRateLimitScope(rawApiKey);
50+
51+
// Unknown/revoked/expired key: fall back to the default per-key bucket.
52+
if (!scope) {
53+
return;
54+
}
55+
56+
return {
57+
config: scope.apiRateLimiterConfig,
58+
identifier: scope.environmentId,
59+
};
60+
}
61+
3362
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
3463
allowPublicKey: true,
3564
allowJWT: true,
@@ -49,15 +78,10 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
4978
};
5079
}
5180

52-
// Additional API keys (`tr_*_sk_*`) share their environment's rate limit
53-
// bucket rather than each getting their own.
54-
const apiKey = authenticatedEnv.apiKey;
55-
const identifier =
56-
apiKey && isAdditionalApiKey(apiKey) ? authenticatedEnv.environment.id : undefined;
57-
81+
// Root/legacy keys keep per-key (hashed header) bucketing: they already map
82+
// 1:1 to an environment.
5883
return {
5984
config: authenticatedEnv.environment.organization.apiRateLimiterConfig,
60-
identifier,
6185
};
6286
},
6387
pathMatchers: [/^\/api/],

apps/webapp/test/authorizationRateLimitMiddleware.test.ts

Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -222,43 +222,38 @@ describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware",
222222
}
223223
);
224224

225-
redisTest(
226-
"should key per token when no identifier is supplied",
227-
async ({ redisOptions }) => {
228-
const rateLimitMiddleware = authorizationRateLimitMiddleware({
229-
redis: { ...redisOptions, tlsDisabled: true },
230-
keyPrefix: "test-no-identifier",
231-
defaultLimiter: {
232-
type: "tokenBucket",
233-
refillRate: 1,
234-
interval: "1m",
235-
maxTokens: 1,
236-
},
237-
pathMatchers: [/^\/api/],
238-
// Override supplies a config but no identifier: bucketing stays per-key
239-
// (hashed Authorization header), the legacy behavior.
240-
limiterConfigOverride: async () => ({
241-
config: { type: "tokenBucket", refillRate: 1, interval: "1m", maxTokens: 1 },
242-
}),
243-
});
225+
redisTest("should key per token when no identifier is supplied", async ({ redisOptions }) => {
226+
const rateLimitMiddleware = authorizationRateLimitMiddleware({
227+
redis: { ...redisOptions, tlsDisabled: true },
228+
keyPrefix: "test-no-identifier",
229+
defaultLimiter: {
230+
type: "tokenBucket",
231+
refillRate: 1,
232+
interval: "1m",
233+
maxTokens: 1,
234+
},
235+
pathMatchers: [/^\/api/],
236+
// Override supplies a config but no identifier: bucketing stays per-key
237+
// (hashed Authorization header), the legacy behavior.
238+
limiterConfigOverride: async () => ({
239+
config: { type: "tokenBucket", refillRate: 1, interval: "1m", maxTokens: 1 },
240+
}),
241+
});
244242

245-
app.use(rateLimitMiddleware);
246-
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
243+
app.use(rateLimitMiddleware);
244+
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
247245

248-
const first = await request(app).get("/api/test").set("Authorization", "Bearer token-a");
249-
expect(first.status).toBe(200);
246+
const first = await request(app).get("/api/test").set("Authorization", "Bearer token-a");
247+
expect(first.status).toBe(200);
250248

251-
// Same token is limited...
252-
const firstAgain = await request(app)
253-
.get("/api/test")
254-
.set("Authorization", "Bearer token-a");
255-
expect(firstAgain.status).toBe(429);
249+
// Same token is limited...
250+
const firstAgain = await request(app).get("/api/test").set("Authorization", "Bearer token-a");
251+
expect(firstAgain.status).toBe(429);
256252

257-
// ...but a different token gets its own bucket.
258-
const second = await request(app).get("/api/test").set("Authorization", "Bearer token-b");
259-
expect(second.status).toBe(200);
260-
}
261-
);
253+
// ...but a different token gets its own bucket.
254+
const second = await request(app).get("/api/test").set("Authorization", "Bearer token-b");
255+
expect(second.status).toBe(200);
256+
});
262257

263258
describe("Advanced Cases", () => {
264259
// 1. Test different rate limit configurations

0 commit comments

Comments
 (0)