Skip to content

Commit 89c584b

Browse files
committed
route additional api keys separately
1 parent 38373fc commit 89c584b

6 files changed

Lines changed: 255 additions & 87 deletions

File tree

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

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.
66
import { logger } from "~/services/logger.server";
77
import { getUsername } from "~/utils/username";
88
import { hashApiKey } from "~/utils/apiKeys";
9+
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
910
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
1011
import { scopesGrantFullAccess } from "@trigger.dev/rbac";
1112

@@ -125,16 +126,18 @@ async function resolveEnvironmentByApiKey(
125126
} satisfies Prisma.RuntimeEnvironmentInclude;
126127

127128
const now = new Date();
128-
let additionalApiKey: { id: string; lastUsedAt: Date | null } | null = null;
129-
let environment = await tx.runtimeEnvironment.findFirst({
130-
where: {
131-
apiKey,
132-
},
133-
include,
134-
});
129+
const routesToAdditionalKey = isAdditionalApiKey(apiKey);
130+
let rootEnvironment = routesToAdditionalKey
131+
? null
132+
: await tx.runtimeEnvironment.findFirst({
133+
where: {
134+
apiKey,
135+
},
136+
include,
137+
});
135138

136139
// Fall back to root keys that were rotated within the grace window.
137-
if (!environment) {
140+
if (!routesToAdditionalKey && !rootEnvironment) {
138141
const revokedApiKey = await tx.revokedApiKey.findFirst({
139142
where: {
140143
apiKey,
@@ -145,34 +148,34 @@ async function resolveEnvironmentByApiKey(
145148
},
146149
});
147150

148-
environment = revokedApiKey?.runtimeEnvironment ?? null;
151+
rootEnvironment = revokedApiKey?.runtimeEnvironment ?? null;
149152
}
150153

151154
// Additional keys are host-owned credentials. Legacy routes cannot apply a
152155
// scoped ability, so only an explicit full-access scope is accepted.
153-
if (!environment) {
154-
const match = await tx.apiKey.findFirst({
155-
where: {
156-
keyHash: hashApiKey(apiKey),
157-
revokedAt: null,
158-
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
159-
},
160-
select: {
161-
id: true,
162-
lastUsedAt: true,
163-
scopes: true,
164-
runtimeEnvironment: { include },
165-
},
166-
});
167-
168-
if (match && !scopesGrantFullAccess(match.scopes)) {
169-
return { ok: false, reason: "restricted" };
170-
}
156+
const match = routesToAdditionalKey
157+
? await tx.apiKey.findFirst({
158+
where: {
159+
keyHash: hashApiKey(apiKey),
160+
revokedAt: null,
161+
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
162+
},
163+
select: {
164+
id: true,
165+
lastUsedAt: true,
166+
scopes: true,
167+
runtimeEnvironment: { include },
168+
},
169+
})
170+
: null;
171171

172-
additionalApiKey = match ? { id: match.id, lastUsedAt: match.lastUsedAt } : null;
173-
environment = match?.runtimeEnvironment ?? null;
172+
if (match && !scopesGrantFullAccess(match.scopes)) {
173+
return { ok: false, reason: "restricted" };
174174
}
175175

176+
const additionalApiKey = match ? { id: match.id, lastUsedAt: match.lastUsedAt } : null;
177+
let environment = rootEnvironment ?? match?.runtimeEnvironment ?? null;
178+
176179
if (!environment) {
177180
return { ok: false, reason: "not-found" };
178181
}

apps/webapp/test/findEnvironmentByApiKey.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { postgresTest } from "@internal/testcontainers";
22
import { type PrismaClient } from "@trigger.dev/database";
3-
import { describe, expect, vi } from "vitest";
3+
import { describe, expect, it, vi } from "vitest";
44
import { findEnvironmentByApiKey } from "~/models/runtimeEnvironment.server";
55
import { generateAdditionalApiKey, hashApiKey } from "~/utils/apiKeys";
66
import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures";
@@ -162,6 +162,43 @@ describe("findEnvironmentByApiKey — non-branchable", () => {
162162
const resolved = await findEnvironmentByApiKey("tr_dev_nonexistent", undefined, prisma);
163163
expect(resolved).toBeNull();
164164
});
165+
166+
it("queries only the additional-key store for a valid additional-key format", async () => {
167+
const runtimeEnvironmentFind = vi.fn();
168+
const revokedApiKeyFind = vi.fn();
169+
const apiKeyFind = vi.fn(async () => null);
170+
const tx = {
171+
runtimeEnvironment: { findFirst: runtimeEnvironmentFind },
172+
revokedApiKey: { findFirst: revokedApiKeyFind },
173+
apiKey: { findFirst: apiKeyFind },
174+
} as unknown as PrismaClient;
175+
176+
await expect(
177+
findEnvironmentByApiKey("tr_prod_sk_0123456789abcdefghijklmn", undefined, tx)
178+
).resolves.toBeNull();
179+
expect(apiKeyFind).toHaveBeenCalledOnce();
180+
expect(runtimeEnvironmentFind).not.toHaveBeenCalled();
181+
expect(revokedApiKeyFind).not.toHaveBeenCalled();
182+
});
183+
184+
it.each(["tr_prod_ak_0123456789abcdefghijklmn", "tr_prod_sk_too-short"])(
185+
"keeps malformed additional-key formats on the root lookup path: %s",
186+
async (apiKey) => {
187+
const runtimeEnvironmentFind = vi.fn(async () => null);
188+
const revokedApiKeyFind = vi.fn(async () => null);
189+
const apiKeyFind = vi.fn();
190+
const tx = {
191+
runtimeEnvironment: { findFirst: runtimeEnvironmentFind },
192+
revokedApiKey: { findFirst: revokedApiKeyFind },
193+
apiKey: { findFirst: apiKeyFind },
194+
} as unknown as PrismaClient;
195+
196+
await expect(findEnvironmentByApiKey(apiKey, undefined, tx)).resolves.toBeNull();
197+
expect(runtimeEnvironmentFind).toHaveBeenCalledOnce();
198+
expect(revokedApiKeyFind).toHaveBeenCalledOnce();
199+
expect(apiKeyFind).not.toHaveBeenCalled();
200+
}
201+
);
165202
});
166203

167204
describe("findEnvironmentByApiKey — additional and disabled keys", () => {

internal-packages/rbac/src/apiKeyPolicies.test.ts

Lines changed: 128 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ type LazyControllerInternals = {
1919
};
2020

2121
const prismaPlaceholder = {} as PrismaClient;
22+
const ADDITIONAL_API_KEY = "tr_prod_sk_0123456789abcdefghijklmn";
23+
const ROOT_API_KEY = "tr_prod_0123456789abcdefghijklmn";
2224
const environment = {
2325
id: "env_123",
2426
organizationId: "org_123",
@@ -53,6 +55,20 @@ function additionalKeyResult(scopes: string[]): AuthSuccess {
5355
};
5456
}
5557

58+
function rootKeyResult(): AuthSuccess {
59+
return {
60+
ok: true,
61+
environment,
62+
subject: {
63+
type: "user",
64+
userId: "user_123",
65+
organizationId: environment.organizationId,
66+
projectId: environment.projectId,
67+
},
68+
ability: buildJwtAbility(["admin"]),
69+
};
70+
}
71+
5672
function publicJwtResult(): AuthSuccess {
5773
return {
5874
ok: true,
@@ -71,6 +87,12 @@ function publicJwt(payload: Record<string, unknown>) {
7187
return `header.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.signature`;
7288
}
7389

90+
function bearerRequest(token: string) {
91+
return new Request("https://api.trigger.dev/test", {
92+
headers: { Authorization: `Bearer ${token}` },
93+
});
94+
}
95+
7496
describe("API-key policy controller composition", () => {
7597
it("routes public JWTs directly to the host without calling the plugin authenticator", async () => {
7698
const pluginAuthenticate = vi.fn();
@@ -94,35 +116,126 @@ describe("API-key policy controller composition", () => {
94116
expect(pluginAuthenticate).not.toHaveBeenCalled();
95117
});
96118

97-
it("uses the scoped host result unchanged when plugin auth falls back to an additional key", async () => {
98-
const pluginAuthenticate = vi.fn(async () => ({
99-
ok: false as const,
100-
status: 401 as const,
101-
error: "Invalid API key",
102-
}));
119+
it("routes valid additional keys directly to the host and preserves their scopes", async () => {
120+
const pluginAuthenticate = vi.fn();
121+
const hostAuthenticate = vi.fn(async () => additionalKeyResult(["write:tasks:send-email"]));
103122
const plugin = {
104123
isUsingPlugin: vi.fn(async () => true),
105124
authenticateBearer: pluginAuthenticate,
106125
} as unknown as RoleBaseAccessController;
107-
const controller = installPlugin(
108-
plugin,
109-
vi.fn(async () => additionalKeyResult(["write:tasks:send-email"]))
110-
);
126+
const controller = installPlugin(plugin, hostAuthenticate);
111127

112-
const result = await controller.authenticateBearer(
113-
new Request("https://api.trigger.dev/test", {
114-
headers: { Authorization: "Bearer tr_additional" },
115-
})
116-
);
128+
const result = await controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY));
117129

118130
expect(result.ok).toBe(true);
131+
expect(hostAuthenticate).toHaveBeenCalledOnce();
132+
expect(pluginAuthenticate).not.toHaveBeenCalled();
119133
if (!result.ok) return;
120134
expect(result.subject).toMatchObject({ type: "apiKey", restricted: true });
121135
expect(result.ability.can("trigger", { type: "tasks", id: "send-email" })).toBe(true);
122136
expect(result.ability.can("trigger", { type: "tasks", id: "other-task" })).toBe(false);
123137
expect(result.ability.can("read", { type: "runs" })).toBe(false);
124138
});
125139

140+
it("returns an unknown additional key's host 401 without calling the plugin", async () => {
141+
const hostFailure = {
142+
ok: false as const,
143+
status: 401 as const,
144+
error: "Invalid API key",
145+
};
146+
const pluginAuthenticate = vi.fn();
147+
const hostAuthenticate = vi.fn(async () => hostFailure);
148+
const plugin = {
149+
isUsingPlugin: vi.fn(async () => true),
150+
authenticateBearer: pluginAuthenticate,
151+
} as unknown as RoleBaseAccessController;
152+
const controller = installPlugin(plugin, hostAuthenticate);
153+
154+
await expect(controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY))).resolves.toEqual(
155+
hostFailure
156+
);
157+
expect(hostAuthenticate).toHaveBeenCalledOnce();
158+
expect(pluginAuthenticate).not.toHaveBeenCalled();
159+
});
160+
161+
it("routes current root keys to the plugin without calling the host", async () => {
162+
const pluginAuthenticate = vi.fn(async () => rootKeyResult());
163+
const hostAuthenticate = vi.fn();
164+
const plugin = {
165+
isUsingPlugin: vi.fn(async () => true),
166+
authenticateBearer: pluginAuthenticate,
167+
} as unknown as RoleBaseAccessController;
168+
const controller = installPlugin(plugin, hostAuthenticate);
169+
170+
await expect(controller.authenticateBearer(bearerRequest(ROOT_API_KEY))).resolves.toMatchObject(
171+
{
172+
ok: true,
173+
subject: { type: "user" },
174+
}
175+
);
176+
expect(pluginAuthenticate).toHaveBeenCalledOnce();
177+
expect(hostAuthenticate).not.toHaveBeenCalled();
178+
});
179+
180+
it.each([401, 403] as const)(
181+
"returns a plugin %s for root-shaped keys without calling the host",
182+
async (status) => {
183+
const pluginFailure = {
184+
ok: false as const,
185+
status,
186+
error: "Unauthorized",
187+
};
188+
const pluginAuthenticate = vi.fn(async () => pluginFailure);
189+
const hostAuthenticate = vi.fn();
190+
const plugin = {
191+
isUsingPlugin: vi.fn(async () => true),
192+
authenticateBearer: pluginAuthenticate,
193+
} as unknown as RoleBaseAccessController;
194+
const controller = installPlugin(plugin, hostAuthenticate);
195+
196+
await expect(controller.authenticateBearer(bearerRequest(ROOT_API_KEY))).resolves.toEqual(
197+
pluginFailure
198+
);
199+
expect(pluginAuthenticate).toHaveBeenCalledOnce();
200+
expect(hostAuthenticate).not.toHaveBeenCalled();
201+
}
202+
);
203+
204+
it("fails closed when an additional-key host route resolves a non-apiKey subject", async () => {
205+
const pluginAuthenticate = vi.fn();
206+
const hostAuthenticate = vi.fn(async () => rootKeyResult());
207+
const plugin = {
208+
isUsingPlugin: vi.fn(async () => true),
209+
authenticateBearer: pluginAuthenticate,
210+
} as unknown as RoleBaseAccessController;
211+
const controller = installPlugin(plugin, hostAuthenticate);
212+
213+
await expect(controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY))).resolves.toEqual(
214+
{
215+
ok: false,
216+
status: 401,
217+
error: "Invalid API key",
218+
}
219+
);
220+
expect(pluginAuthenticate).not.toHaveBeenCalled();
221+
});
222+
223+
it("keeps additional-key authentication in the no-plugin fallback controller", async () => {
224+
const fallbackAuthenticate = vi.fn(async () => additionalKeyResult(["admin"]));
225+
const hostAuthenticate = vi.fn();
226+
const fallback = {
227+
isUsingPlugin: vi.fn(async () => false),
228+
authenticateBearer: fallbackAuthenticate,
229+
} as unknown as RoleBaseAccessController;
230+
const controller = installPlugin(fallback, hostAuthenticate);
231+
232+
await expect(
233+
controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY))
234+
).resolves.toMatchObject({ ok: true, subject: { type: "apiKey" } });
235+
expect(fallbackAuthenticate).toHaveBeenCalledOnce();
236+
expect(hostAuthenticate).not.toHaveBeenCalled();
237+
});
238+
126239
it("delegates API-key policy catalogue, preparation, and description", async () => {
127240
const presets = vi.fn(async () => []);
128241
const prepare = vi.fn(async () => ({

0 commit comments

Comments
 (0)