Skip to content

Commit 763b5dc

Browse files
authored
feat(webapp): enforce scopes for environment API keys (#4389)
## Summary Environment API keys backed by the additional-key table can authenticate API requests using their stored effective scopes. Revoked and expired keys are rejected, branch environments retain their existing routing behavior, and last-used timestamps are updated on a throttled best-effort basis. ## Design API route builders receive the resolved ability and reject restricted keys on routes without an authorization declaration. Existing deployment, environment variable, queue, run, task, batch, session, and waitpoint routes declare the resources they access. Trigger and batch responses return server-signed public access tokens, so additional keys never need access to the environment signing secret. Root-key rotation also keeps public tokens valid for the existing grace window. ## Feature notes - Root environment keys remain unrestricted for backward compatibility. Additional keys enforce their persisted scopes and fail closed on routes without an authorization declaration. - Machine-key requests never exchange one credential for another. Additional keys cannot retrieve the root key, and rotated root keys are not upgraded during their grace window. - Public JWT validation remains host-owned, while installed RBAC plugins continue to supply root-key abilities. - Unfiltered session and run listings preserve existing broad task-read behavior. Filtered requests enforce the supplied task identifiers. - Related-run summaries remain embedded in run retrieval for API compatibility. Retrieving or mutating a related run independently still requires permission for that run. - Queue management authorizes at collection scope, matching the queue permissions currently issued. - Batch responses deliberately include server-signed public access tokens for all clients. Selected-task credentials continue using their original credential for per-item authorization. - Two-phase batches authorize declared task identifiers before creation and authorize every streamed item. Streaming paths that cannot declare the complete task set remain fail closed. - Authentication telemetry records successful credential resolution separately from subsequent resource-authorization failures. - API keys are high-entropy random tokens. SHA-256 is intentionally used for deterministic indexed lookup, not password hashing. ## Deployment notes The schema migration must be present before this code is deployed. Because bearer resolution runs on every authenticated request, deploy the resolver with additional-key lookup disabled, verify root-key and public-token parity, then enable lookup before any additional keys can be issued. The multi-task authorization tightening changes the result for narrowly scoped tokens that request tasks outside their grants. Observe would-deny results before enforcing that check. Request-idempotency keys are also newly isolated by environment and task, so a retry crossing the deployment boundary may execute once more before old cache entries expire. ## Follow-ups - [x] Add a system-wide kill switch for additional-key lookup, defaulted off for the initial deployment. - [x] Add authentication observability by credential kind, result, latency, and lookup path without recording credential values. - [ ] ~Add would-deny observability and an independent enforcement switch for multi-task authorization.~ - [ ] ~Add an independent switch for server-issued batch tokens while root-key parity is verified.~ - [ ] Confirm every API route reachable by a restricted key has an explicit authorization declaration or intentionally fails closed. - [x] Verify root-key rotation, revoked-key grace, and public-token validation through each bearer resolver path.
1 parent d9f4fea commit 763b5dc

76 files changed

Lines changed: 3819 additions & 850 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.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Allow task-scoped environment API keys to run batch operations for their permitted tasks. The SDK declares the batch's task set before creation, and `@trigger.dev/core/v3/apiKeys` now exports the additional-key format helper.
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+
Requests spanning multiple tasks now require permission for every requested task instead of accepting permission for only one task.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Additional environment API keys can authenticate API requests using their configured permissions, with revoked and expired keys rejected. Batch responses use server-issued public access tokens so additional keys never need the environment signing secret.

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

Lines changed: 146 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ 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";
9+
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
810
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
11+
import { scopesGrantFullAccess } from "@trigger.dev/rbac";
12+
import { authFeatureControls } from "~/services/authFeatureControls.server";
913

1014
export type { RuntimeEnvironment };
1115

@@ -93,11 +97,22 @@ export function toAuthenticated(
9397
};
9498
}
9599

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

103118
const include = {
@@ -112,80 +127,178 @@ export async function findEnvironmentByApiKey(
112127
: undefined,
113128
} satisfies Prisma.RuntimeEnvironmentInclude;
114129

115-
let environment = await tx.runtimeEnvironment.findFirst({
116-
where: {
117-
apiKey,
118-
},
119-
include,
120-
});
130+
const now = new Date();
131+
const routesToAdditionalKey = isAdditionalApiKey(apiKey);
132+
if (routesToAdditionalKey && !additionalApiKeyLookupEnabled()) {
133+
return { ok: false, reason: "disabled" };
134+
}
121135

122-
// Fall back to keys that were revoked within the grace window
123-
if (!environment) {
136+
let rootEnvironment = routesToAdditionalKey
137+
? null
138+
: await tx.runtimeEnvironment.findFirst({
139+
where: {
140+
apiKey,
141+
},
142+
include,
143+
});
144+
145+
// Fall back to root keys that were rotated within the grace window.
146+
if (!routesToAdditionalKey && !rootEnvironment) {
124147
const revokedApiKey = await tx.revokedApiKey.findFirst({
125148
where: {
126149
apiKey,
127-
expiresAt: { gt: new Date() },
150+
expiresAt: { gt: now },
128151
},
129152
include: {
130153
runtimeEnvironment: { include },
131154
},
132155
});
133156

134-
environment = revokedApiKey?.runtimeEnvironment ?? null;
157+
rootEnvironment = revokedApiKey?.runtimeEnvironment ?? null;
135158
}
136159

160+
// Additional keys are host-owned credentials. Legacy routes cannot apply a
161+
// scoped ability, so only an explicit full-access scope is accepted.
162+
const match = routesToAdditionalKey
163+
? await tx.apiKey.findFirst({
164+
where: {
165+
keyHash: hashApiKey(apiKey),
166+
revokedAt: null,
167+
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
168+
},
169+
select: {
170+
id: true,
171+
lastUsedAt: true,
172+
scopes: true,
173+
runtimeEnvironment: { include },
174+
},
175+
})
176+
: null;
177+
178+
if (match && !scopesGrantFullAccess(match.scopes)) {
179+
return { ok: false, reason: "restricted" };
180+
}
181+
182+
const additionalApiKey = match ? { id: match.id, lastUsedAt: match.lastUsedAt } : null;
183+
let environment = rootEnvironment ?? match?.runtimeEnvironment ?? null;
184+
137185
if (!environment) {
138-
return null;
186+
return { ok: false, reason: "not-found" };
187+
}
188+
189+
if (
190+
additionalApiKey &&
191+
(!additionalApiKey.lastUsedAt ||
192+
additionalApiKey.lastUsedAt < new Date(now.getTime() - 300_000))
193+
) {
194+
try {
195+
// Deliberately the primary `prisma`, not `tx`: `tx` defaults to the
196+
// read replica (and may be a caller's transaction), and this last-used
197+
// telemetry write must hit the writer. It's throttled to once every 5
198+
// minutes per key and best-effort — auth never fails if it can't record.
199+
await prisma.apiKey.updateMany({
200+
where: {
201+
id: additionalApiKey.id,
202+
revokedAt: null,
203+
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
204+
},
205+
data: { lastUsedAt: now },
206+
});
207+
} catch (error) {
208+
logger.warn("Failed to update API key last-used timestamp", {
209+
apiKeyId: additionalApiKey.id,
210+
error,
211+
});
212+
}
139213
}
140214

141215
//don't return deleted projects
142216
if (environment.project.deletedAt !== null) {
143-
return null;
217+
return { ok: false, reason: "not-found" };
144218
}
145219

146220
if (environment.type === "PREVIEW") {
147221
if (!branch) {
148222
logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", {
149223
environmentId: environment.id,
150224
});
151-
return null;
225+
return { ok: false, reason: "not-found" };
152226
}
153227

154228
const childEnvironment = environment.childEnvironments.at(0);
155229

156230
if (childEnvironment) {
157-
return toAuthenticated({
158-
...childEnvironment,
159-
apiKey: environment.apiKey,
160-
orgMember: environment.orgMember,
161-
organization: environment.organization,
162-
project: environment.project,
163-
});
231+
return {
232+
ok: true,
233+
environment: toAuthenticated({
234+
...childEnvironment,
235+
apiKey: environment.apiKey,
236+
orgMember: environment.orgMember,
237+
organization: environment.organization,
238+
project: environment.project,
239+
}),
240+
};
164241
}
165242

166243
//A branch was specified but no child environment was found
167-
return null;
244+
return { ok: false, reason: "not-found" };
168245
}
169246

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

174251
if (childEnvironment) {
175-
return toAuthenticated({
176-
...childEnvironment,
177-
apiKey: environment.apiKey,
178-
orgMember: environment.orgMember,
179-
organization: environment.organization,
180-
project: environment.project,
181-
});
252+
return {
253+
ok: true,
254+
environment: toAuthenticated({
255+
...childEnvironment,
256+
apiKey: environment.apiKey,
257+
orgMember: environment.orgMember,
258+
organization: environment.organization,
259+
project: environment.project,
260+
}),
261+
};
182262
}
183263

184264
//A branch was specified but no child environment was found
185-
return null;
265+
return { ok: false, reason: "not-found" };
186266
}
187267

188-
return toAuthenticated(environment);
268+
return { ok: true, environment: toAuthenticated(environment) };
269+
}
270+
271+
/**
272+
* Resolve an environment from a raw API key. Root and grace-window keys keep
273+
* their legacy behavior; additional keys with restricted scopes fail closed.
274+
*/
275+
export async function findEnvironmentByApiKey(
276+
apiKey: string,
277+
branchName: string | undefined,
278+
tx: PrismaClientOrTransaction = $replica,
279+
additionalApiKeyLookupEnabled = authFeatureControls.additionalApiKeyLookupEnabled
280+
): Promise<AuthenticatedEnvironment | null> {
281+
const resolution = await resolveEnvironmentByApiKey(
282+
apiKey,
283+
branchName,
284+
tx,
285+
additionalApiKeyLookupEnabled
286+
);
287+
return resolution.ok ? resolution.environment : null;
288+
}
289+
290+
/**
291+
* Like `findEnvironmentByApiKey`, but distinguishes a restricted additional
292+
* key (fails closed on legacy routes) from an unknown key so callers can
293+
* return an accurate error message.
294+
*/
295+
export async function findEnvironmentByApiKeyWithResolution(
296+
apiKey: string,
297+
branchName: string | undefined,
298+
tx: PrismaClientOrTransaction = $replica,
299+
additionalApiKeyLookupEnabled = authFeatureControls.additionalApiKeyLookupEnabled
300+
): Promise<ApiKeyEnvironmentResolution> {
301+
return resolveEnvironmentByApiKey(apiKey, branchName, tx, additionalApiKeyLookupEnabled);
189302
}
190303

191304
/**

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,8 @@ export class ApiRetrieveRunPresenter {
289289
attemptCount:
290290
taskRun.engine === "V1" ? taskRun.attempts.length : (taskRun.attemptNumber ?? 0),
291291
attempts: [],
292+
// Related runs are an embedded projection of the authorized run, not independent reads.
293+
// Preserve the established response shape for run-scoped credentials.
292294
relatedRuns: {
293295
root: taskRun.rootTaskRun
294296
? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion)
@@ -297,7 +299,9 @@ export class ApiRetrieveRunPresenter {
297299
? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion)
298300
: undefined,
299301
children: await Promise.all(
300-
taskRun.childRuns.map(async (r) => await createCommonRunStructure(r, this.apiVersion))
302+
taskRun.childRuns.map(
303+
async (run) => await createCommonRunStructure(run, this.apiVersion)
304+
)
301305
),
302306
},
303307
};

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)