Skip to content

Commit ce81fb3

Browse files
committed
use new resolver for api keys
1 parent a71d188 commit ce81fb3

3 files changed

Lines changed: 102 additions & 52 deletions

File tree

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

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

304-
export type AdditionalApiKeyRateLimitScope = {
304+
export type PrivateApiKeyRateLimitScope = {
305305
environmentId: string;
306-
// Organization rate limiter override (bucket size), if configured.
307306
apiRateLimiterConfig: unknown;
308307
};
309308

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(
309+
export async function resolvePrivateApiKeyRateLimitScope(
323310
apiKey: string,
324311
tx: PrismaClientOrTransaction = $replica
325-
): Promise<AdditionalApiKeyRateLimitScope | null> {
326-
if (!isAdditionalApiKey(apiKey)) {
327-
return null;
328-
}
329-
312+
): Promise<PrivateApiKeyRateLimitScope | null> {
330313
const now = new Date();
331314

332-
const match = await tx.apiKey.findFirst({
333-
where: {
334-
keyHash: hashApiKey(apiKey),
335-
revokedAt: null,
336-
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
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+
organization: { select: { apiRateLimiterConfig: true } },
327+
},
328+
},
329+
},
330+
});
331+
332+
if (!match?.runtimeEnvironment) {
333+
return null;
334+
}
335+
336+
return {
337+
environmentId: match.runtimeEnvironment.id,
338+
apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig,
339+
};
340+
}
341+
342+
const environment = await tx.runtimeEnvironment.findFirst({
343+
where: { apiKey },
344+
select: {
345+
id: true,
346+
project: { select: { deletedAt: true } },
347+
organization: { select: { apiRateLimiterConfig: true } },
337348
},
349+
});
350+
351+
if (environment) {
352+
if (environment.project.deletedAt) {
353+
return null;
354+
}
355+
356+
return {
357+
environmentId: environment.id,
358+
apiRateLimiterConfig: environment.organization.apiRateLimiterConfig,
359+
};
360+
}
361+
362+
const revokedApiKey = await tx.revokedApiKey.findFirst({
363+
where: { apiKey, expiresAt: { gt: now } },
338364
select: {
339365
runtimeEnvironment: {
340366
select: {
341367
id: true,
368+
project: { select: { deletedAt: true } },
342369
organization: { select: { apiRateLimiterConfig: true } },
343370
},
344371
},
345372
},
346373
});
347374

348-
if (!match?.runtimeEnvironment) {
375+
const revokedEnvironment = revokedApiKey?.runtimeEnvironment;
376+
if (!revokedEnvironment || revokedEnvironment.project.deletedAt) {
349377
return null;
350378
}
351379

352380
return {
353-
environmentId: match.runtimeEnvironment.id,
354-
apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig,
381+
environmentId: revokedEnvironment.id,
382+
apiRateLimiterConfig: revokedEnvironment.organization.apiRateLimiterConfig,
355383
};
356384
}
357385

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

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { tryCatch } from "@trigger.dev/core/v3";
2-
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
2+
import { trail } from "agentcrumbs"; // @crumbs
33
import { env } from "~/env.server";
4-
import { resolveAdditionalApiKeyRateLimitScope } from "~/models/runtimeEnvironment.server";
4+
import { resolvePrivateApiKeyRateLimitScope } from "~/models/runtimeEnvironment.server";
55
import { batchStreamGrants } from "~/runEngine/concerns/batchStreamGrantsInstance.server";
66
import { authenticateAuthorizationHeader } from "./apiAuth.server";
77
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
88
import type { Duration } from "./rateLimiter.server";
99

1010
const BATCH_STREAM_ITEMS_PATH = /^\/api\/v3\/batches\/([^/]+)\/items$/;
11+
const crumb = trail("webapp"); // @crumbs
1112

1213
export const apiRateLimiter = authorizationRateLimitMiddleware({
1314
redis: {
@@ -33,26 +34,19 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
3334
limiterConfigOverride: async (authorizationValue) => {
3435
const rawApiKey = authorizationValue.replace(/^Bearer /, "");
3536

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.
37+
if (rawApiKey.startsWith("tr_")) {
38+
const scope = await resolvePrivateApiKeyRateLimitScope(rawApiKey);
39+
5240
if (!scope) {
5341
return;
5442
}
5543

44+
// #region @crumbs
45+
crumb("resolved private API key rate limit scope", {
46+
environmentId: scope.environmentId,
47+
});
48+
// #endregion @crumbs
49+
5650
return {
5751
config: scope.apiRateLimiterConfig,
5852
identifier: scope.environmentId,
@@ -78,11 +72,6 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
7872
};
7973
}
8074

81-
// Root/legacy keys also bucket per environment, so an environment's ceiling
82-
// is exactly its configured limit regardless of key mix (root + additional
83-
// keys share one bucket). The environment is already resolved above, so this
84-
// adds no lookup. JWTs intentionally stay on per-token bucketing (handled
85-
// above).
8675
return {
8776
config: authenticatedEnv.environment.organization.apiRateLimiterConfig,
8877
identifier: authenticatedEnv.environment.id,

apps/webapp/test/findEnvironmentByApiKey.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { postgresTest } from "@internal/testcontainers";
22
import { type PrismaClient } from "@trigger.dev/database";
33
import { describe, expect, it, vi } from "vitest";
4-
import { findEnvironmentByApiKey } from "~/models/runtimeEnvironment.server";
4+
import {
5+
findEnvironmentByApiKey,
6+
resolvePrivateApiKeyRateLimitScope,
7+
} from "~/models/runtimeEnvironment.server";
58
import { generateAdditionalApiKey, hashApiKey } from "~/utils/apiKeys";
69
import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures";
710

@@ -143,6 +146,36 @@ describe("findEnvironmentByApiKey — PREVIEW (regression guard)", () => {
143146
expect(resolved?.apiKey).toBe(previewParent.apiKey);
144147
}
145148
);
149+
150+
postgresTest(
151+
"rate limit scope resolves root and additional keys to the preview parent",
152+
async ({ prisma }) => {
153+
const { organization, project, user } = await createTestOrgProjectWithMember(prisma);
154+
const previewParent = await createEnv(prisma, project.id, organization.id, {
155+
type: "PREVIEW",
156+
isBranchableEnvironment: true,
157+
});
158+
const additional = generateAdditionalApiKey("PREVIEW").apiKey;
159+
160+
await prisma.apiKey.create({
161+
data: {
162+
name: "Preview integration",
163+
keyHash: hashApiKey(additional),
164+
lastFour: additional.slice(-4),
165+
runtimeEnvironmentId: previewParent.id,
166+
createdByUserId: user.id,
167+
presetId: null,
168+
scopes: ["admin"],
169+
},
170+
});
171+
172+
const rootScope = await resolvePrivateApiKeyRateLimitScope(previewParent.apiKey, prisma);
173+
const additionalScope = await resolvePrivateApiKeyRateLimitScope(additional, prisma);
174+
175+
expect(rootScope?.environmentId).toBe(previewParent.id);
176+
expect(additionalScope?.environmentId).toBe(previewParent.id);
177+
}
178+
);
146179
});
147180

148181
describe("findEnvironmentByApiKey — non-branchable", () => {

0 commit comments

Comments
 (0)