Skip to content

Commit 37de624

Browse files
committed
feat(webapp): enforce scopes for environment API keys
1 parent 0e70a0b commit 37de624

62 files changed

Lines changed: 2874 additions & 819 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 122 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { runStore } from "~/v3/runStore.server";
55
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
66
import { logger } from "~/services/logger.server";
77
import { getUsername } from "~/utils/username";
8+
import { hashApiKey } from "~/utils/apiKeys";
89
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
10+
import { scopesGrantFullAccess } from "@trigger.dev/rbac";
911

1012
export type { RuntimeEnvironment };
1113

@@ -93,11 +95,21 @@ export function toAuthenticated(
9395
};
9496
}
9597

96-
export async function findEnvironmentByApiKey(
98+
export type ApiKeyEnvironmentResolution =
99+
| { ok: true; environment: AuthenticatedEnvironment }
100+
| { ok: false; reason: "not-found" | "restricted" };
101+
102+
/**
103+
* Resolve an environment from a raw API key for legacy routes that do not
104+
* declare authorization. Additional keys are accepted only when their stored
105+
* scopes explicitly grant full access; restricted keys fail closed here
106+
* (`reason: "restricted"`, so callers can explain the rejection).
107+
*/
108+
async function resolveEnvironmentByApiKey(
97109
apiKey: string,
98110
branchName: string | undefined,
99-
tx: PrismaClientOrTransaction = $replica
100-
): Promise<AuthenticatedEnvironment | null> {
111+
tx: PrismaClientOrTransaction
112+
): Promise<ApiKeyEnvironmentResolution> {
101113
const branch = sanitizeBranchName(branchName) ?? undefined;
102114

103115
const include = {
@@ -112,19 +124,21 @@ export async function findEnvironmentByApiKey(
112124
: undefined,
113125
} satisfies Prisma.RuntimeEnvironmentInclude;
114126

127+
const now = new Date();
128+
let additionalApiKey: { id: string; lastUsedAt: Date | null } | null = null;
115129
let environment = await tx.runtimeEnvironment.findFirst({
116130
where: {
117131
apiKey,
118132
},
119133
include,
120134
});
121135

122-
// Fall back to keys that were revoked within the grace window
136+
// Fall back to root keys that were rotated within the grace window.
123137
if (!environment) {
124138
const revokedApiKey = await tx.revokedApiKey.findFirst({
125139
where: {
126140
apiKey,
127-
expiresAt: { gt: new Date() },
141+
expiresAt: { gt: now },
128142
},
129143
include: {
130144
runtimeEnvironment: { include },
@@ -134,58 +148,141 @@ export async function findEnvironmentByApiKey(
134148
environment = revokedApiKey?.runtimeEnvironment ?? null;
135149
}
136150

151+
// Additional keys are host-owned credentials. Legacy routes cannot apply a
152+
// scoped ability, so only an explicit full-access scope is accepted.
137153
if (!environment) {
138-
return null;
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+
}
171+
172+
additionalApiKey = match ? { id: match.id, lastUsedAt: match.lastUsedAt } : null;
173+
environment = match?.runtimeEnvironment ?? null;
174+
}
175+
176+
if (!environment) {
177+
return { ok: false, reason: "not-found" };
178+
}
179+
180+
if (
181+
additionalApiKey &&
182+
(!additionalApiKey.lastUsedAt ||
183+
additionalApiKey.lastUsedAt < new Date(now.getTime() - 300_000))
184+
) {
185+
try {
186+
// Deliberately the primary `prisma`, not `tx`: `tx` defaults to the
187+
// read replica (and may be a caller's transaction), and this last-used
188+
// telemetry write must hit the writer. It's throttled to once every 5
189+
// minutes per key and best-effort — auth never fails if it can't record.
190+
await prisma.apiKey.updateMany({
191+
where: {
192+
id: additionalApiKey.id,
193+
revokedAt: null,
194+
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
195+
},
196+
data: { lastUsedAt: now },
197+
});
198+
} catch (error) {
199+
logger.warn("Failed to update API key last-used timestamp", {
200+
apiKeyId: additionalApiKey.id,
201+
error,
202+
});
203+
}
139204
}
140205

141206
//don't return deleted projects
142207
if (environment.project.deletedAt !== null) {
143-
return null;
208+
return { ok: false, reason: "not-found" };
144209
}
145210

146211
if (environment.type === "PREVIEW") {
147212
if (!branch) {
148213
logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", {
149214
environmentId: environment.id,
150215
});
151-
return null;
216+
return { ok: false, reason: "not-found" };
152217
}
153218

154219
const childEnvironment = environment.childEnvironments.at(0);
155220

156221
if (childEnvironment) {
157-
return toAuthenticated({
158-
...childEnvironment,
159-
apiKey: environment.apiKey,
160-
orgMember: environment.orgMember,
161-
organization: environment.organization,
162-
project: environment.project,
163-
});
222+
return {
223+
ok: true,
224+
environment: toAuthenticated({
225+
...childEnvironment,
226+
apiKey: environment.apiKey,
227+
orgMember: environment.orgMember,
228+
organization: environment.organization,
229+
project: environment.project,
230+
}),
231+
};
164232
}
165233

166234
//A branch was specified but no child environment was found
167-
return null;
235+
return { ok: false, reason: "not-found" };
168236
}
169237

170238
// If there is a named DEV branch (other than default), return it
171239
if (environment.type === "DEVELOPMENT" && branch !== undefined && !isDefaultDevBranch(branch)) {
172240
const childEnvironment = environment.childEnvironments.at(0);
173241

174242
if (childEnvironment) {
175-
return toAuthenticated({
176-
...childEnvironment,
177-
apiKey: environment.apiKey,
178-
orgMember: environment.orgMember,
179-
organization: environment.organization,
180-
project: environment.project,
181-
});
243+
return {
244+
ok: true,
245+
environment: toAuthenticated({
246+
...childEnvironment,
247+
apiKey: environment.apiKey,
248+
orgMember: environment.orgMember,
249+
organization: environment.organization,
250+
project: environment.project,
251+
}),
252+
};
182253
}
183254

184255
//A branch was specified but no child environment was found
185-
return null;
256+
return { ok: false, reason: "not-found" };
186257
}
187258

188-
return toAuthenticated(environment);
259+
return { ok: true, environment: toAuthenticated(environment) };
260+
}
261+
262+
/**
263+
* Resolve an environment from a raw API key. Root and grace-window keys keep
264+
* their legacy behavior; additional keys with restricted scopes fail closed.
265+
*/
266+
export async function findEnvironmentByApiKey(
267+
apiKey: string,
268+
branchName: string | undefined,
269+
tx: PrismaClientOrTransaction = $replica
270+
): Promise<AuthenticatedEnvironment | null> {
271+
const resolution = await resolveEnvironmentByApiKey(apiKey, branchName, tx);
272+
return resolution.ok ? resolution.environment : null;
273+
}
274+
275+
/**
276+
* Like `findEnvironmentByApiKey`, but distinguishes a restricted additional
277+
* key (fails closed on legacy routes) from an unknown key so callers can
278+
* return an accurate error message.
279+
*/
280+
export async function findEnvironmentByApiKeyWithResolution(
281+
apiKey: string,
282+
branchName: string | undefined,
283+
tx: PrismaClientOrTransaction = $replica
284+
): Promise<ApiKeyEnvironmentResolution> {
285+
return resolveEnvironmentByApiKey(apiKey, branchName, tx);
189286
}
190287

191288
/**

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

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization";
1414
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
1515
import { getUserProvidedIdempotencyKey } from "@trigger.dev/core/v3/serverOnly";
1616
import type { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
17+
import type { RbacAbility } from "@trigger.dev/rbac";
1718
import assertNever from "assert-never";
1819
import type { API_VERSIONS, RunStatusUnspecifiedApiVersion } from "~/api/versions";
1920
import { CURRENT_API_VERSION } from "~/api/versions";
@@ -83,6 +84,24 @@ type CommonRelatedRunWithVersion = CommonRelatedRun & {
8384
// ReturnType<typeof findRun>) so findRun can return a synthesised buffered
8485
// run without the type becoming self-referential. Exported so the
8586
// buffer-synthesis helper below can match this shape under unit test.
87+
function canReadRelatedRun(
88+
ability: RbacAbility | undefined,
89+
run: CommonRelatedRunWithVersion
90+
): boolean {
91+
if (!ability) return true;
92+
93+
const resources = [
94+
{ type: "runs", id: run.friendlyId },
95+
{ type: "tasks", id: run.taskIdentifier },
96+
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
97+
];
98+
if (run.batch?.friendlyId) {
99+
resources.push({ type: "batch", id: run.batch.friendlyId });
100+
}
101+
102+
return ability.can("read", resources);
103+
}
104+
86105
export type FoundRun = CommonRelatedRunWithVersion & {
87106
traceId: string;
88107
payload: string;
@@ -212,7 +231,7 @@ export class ApiRetrieveRunPresenter {
212231
return synthesiseFoundRunFromBuffer(buffered);
213232
}
214233

215-
public async call(taskRun: FoundRun, env: AuthenticatedEnvironment) {
234+
public async call(taskRun: FoundRun, env: AuthenticatedEnvironment, ability?: RbacAbility) {
216235
return startSpanWithEnv(tracer, "ApiRetrieveRunPresenter.call", env, async () => {
217236
let $payload: any;
218237
let $payloadPresignedUrl: string | undefined;
@@ -290,14 +309,18 @@ export class ApiRetrieveRunPresenter {
290309
taskRun.engine === "V1" ? taskRun.attempts.length : (taskRun.attemptNumber ?? 0),
291310
attempts: [],
292311
relatedRuns: {
293-
root: taskRun.rootTaskRun
294-
? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion)
295-
: undefined,
296-
parent: taskRun.parentTaskRun
297-
? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion)
298-
: undefined,
312+
root:
313+
taskRun.rootTaskRun && canReadRelatedRun(ability, taskRun.rootTaskRun)
314+
? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion)
315+
: undefined,
316+
parent:
317+
taskRun.parentTaskRun && canReadRelatedRun(ability, taskRun.parentTaskRun)
318+
? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion)
319+
: undefined,
299320
children: await Promise.all(
300-
taskRun.childRuns.map(async (r) => await createCommonRunStructure(r, this.apiVersion))
321+
taskRun.childRuns
322+
.filter((run) => canReadRelatedRun(ability, run))
323+
.map(async (run) => await createCommonRunStructure(run, this.apiVersion))
301324
),
302325
},
303326
};

apps/webapp/app/routes/api.v1.artifacts.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
CreateArtifactRequestBody,
55
tryCatch,
66
} from "@trigger.dev/core/v3";
7-
import { authenticateRequest } from "~/services/apiAuth.server";
7+
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
88
import { logger } from "~/services/logger.server";
99
import { ArtifactsService } from "~/v3/services/artifacts.server";
1010

@@ -14,17 +14,19 @@ export async function action({ request }: ActionFunctionArgs) {
1414
}
1515

1616
try {
17-
const authenticationResult = await authenticateRequest(request, {
18-
apiKey: true,
19-
organizationAccessToken: false,
20-
personalAccessToken: false,
17+
// Artifact uploads are part of the deploy flow (deployment context archive).
18+
const authResult = await authenticateApiKeyWithScope(request, {
19+
action: "write",
20+
resource: { type: "deployments" },
2121
});
2222

23-
if (!authenticationResult || !authenticationResult.result.ok) {
23+
if (!authResult.ok) {
2424
logger.info("Invalid or missing api key", { url: request.url });
25-
return json({ error: "Invalid or Missing API key" }, { status: 401 });
25+
return json({ error: authResult.error }, { status: authResult.status });
2626
}
2727

28+
const authenticationResult = { result: authResult.authentication };
29+
2830
const [, rawBody] = await tryCatch(request.json());
2931
const body = CreateArtifactRequestBody.safeParse(rawBody ?? {});
3032

apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
44
import { z } from "zod";
5-
import { authenticateApiRequest } from "~/services/apiAuth.server";
5+
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
66
import { logger } from "~/services/logger.server";
77
import { ServiceValidationError } from "~/v3/services/baseService.server";
88
import { CreateDeclarativeScheduleError } from "~/v3/services/createBackgroundWorker.server";
@@ -26,13 +26,18 @@ export async function action({ request, params }: ActionFunctionArgs) {
2626

2727
try {
2828
// Next authenticate the request
29-
const authenticationResult = await authenticateApiRequest(request);
29+
const authResult = await authenticateApiKeyWithScope(request, {
30+
action: "write",
31+
resource: { type: "deployments" },
32+
});
3033

31-
if (!authenticationResult) {
34+
if (!authResult.ok) {
3235
logger.info("Invalid or missing api key", { url: request.url });
33-
return json({ error: "Invalid or Missing API key" }, { status: 401 });
36+
return json({ error: authResult.error }, { status: authResult.status });
3437
}
3538

39+
const authenticationResult = authResult.authentication;
40+
3641
const authenticatedEnv = authenticationResult.environment;
3742

3843
const { deploymentId } = parsedParams.data;

apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { CancelDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
33
import { z } from "zod";
4-
import { authenticateRequest } from "~/services/apiAuth.server";
4+
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
55
import { logger } from "~/services/logger.server";
66
import { DeploymentService } from "~/v3/services/deployment.server";
77

@@ -21,18 +21,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
2121
}
2222

2323
try {
24-
const authenticationResult = await authenticateRequest(request, {
25-
apiKey: true,
26-
organizationAccessToken: false,
27-
personalAccessToken: false,
24+
const authResult = await authenticateApiKeyWithScope(request, {
25+
action: "write",
26+
resource: { type: "deployments" },
2827
});
2928

30-
if (!authenticationResult || !authenticationResult.result.ok) {
29+
if (!authResult.ok) {
3130
logger.info("Invalid or missing api key", { url: request.url });
32-
return json({ error: "Invalid or Missing API key" }, { status: 401 });
31+
return json({ error: authResult.error }, { status: authResult.status });
3332
}
3433

35-
const { environment: authenticatedEnv } = authenticationResult.result;
34+
const { environment: authenticatedEnv } = authResult.authentication;
3635
const { deploymentId } = parsedParams.data;
3736

3837
const [, rawBody] = await tryCatch(request.json());

0 commit comments

Comments
 (0)