Skip to content

Commit cca1ea6

Browse files
committed
feat(cli,webapp): allow deploys with environment API keys
1 parent 90e8bd5 commit cca1ea6

20 files changed

Lines changed: 645 additions & 104 deletions

File tree

.changeset/deploy-api-keys.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
Allow `trigger deploy` to authenticate with an environment API key from `TRIGGER_SECRET_KEY`, including deploy-only keys and Preview deployments.

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,7 @@ const API_KEY_EXPIRATIONS = [
872872
{ value: "never", label: "Never" },
873873
];
874874

875-
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "envvars";
875+
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "branches" | "envvars";
876876

877877
// Capability rows shown in the scope pane, in a fixed order so two presets read
878878
// as a diff of the same list rather than a reshuffled one.
@@ -882,6 +882,7 @@ const SCOPE_CAPABILITIES: [CapId, string][] = [
882882
["batches", "Batches"],
883883
["queues", "Queues"],
884884
["deployments", "Deployments"],
885+
["branches", "Preview branches"],
885886
["envvars", "Environment variables"],
886887
];
887888

@@ -918,6 +919,7 @@ const SCOPE_CAPABILITY_BY_SCOPE: Record<string, [CapId, number]> = {
918919
"write:queues": ["queues", 2],
919920
"read:deployments": ["deployments", 1],
920921
"write:deployments": ["deployments", 2],
922+
"write:branches": ["branches", 3],
921923
"read:envvars": ["envvars", 1],
922924
"write:envvars": ["envvars", 2],
923925
};

apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import {
88
} from "~/services/apiAuth.server";
99
import { logger } from "~/services/logger.server";
1010
import {
11-
authenticateEnvironmentScopedApiRequest,
11+
apiKeyForProjectEnvironmentBootstrap,
12+
authenticateEnvironmentBootstrapRequest,
1213
authorizePatEnvironmentAccess,
13-
presentedApiKeyFromAuthentication,
1414
} from "~/services/environmentVariableApiAccess.server";
1515

1616
const ParamsSchema = z.object({
@@ -30,9 +30,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
3030
const { projectRef, env } = parsedParams.data;
3131

3232
try {
33-
// PAT/OAT authenticate on the legacy path; machine API keys go through
34-
// the RBAC controller so additional keys (and their grants) are enforced.
35-
const authResult = await authenticateEnvironmentScopedApiRequest(request, "read", "apiKeys");
33+
// PAT/OAT authenticate on the legacy path; machine API keys only need to
34+
// prove they are valid because bootstrap echoes the same key back.
35+
const authResult = await authenticateEnvironmentBootstrapRequest(request);
3636
if (!authResult.ok) {
3737
return json({ error: authResult.error }, { status: authResult.status });
3838
}
@@ -46,29 +46,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
4646
);
4747

4848
// User tokens bootstrap the environment's secret key, so gate them on
49-
// env-tier read:apiKeys. Machine credentials are checked against the same
50-
// permission before their presented key is returned below.
51-
const denied = await authorizePatEnvironmentAccess({
52-
request,
53-
authType: authenticationResult.type,
54-
ability:
55-
authenticationResult.type === "apiKey" && authenticationResult.result.ok
56-
? authenticationResult.result.ability
57-
: undefined,
58-
organizationId: environment.organizationId,
59-
projectId: environment.project.id,
60-
envType: environment.type,
61-
resource: "apiKeys",
62-
action: "read",
63-
});
64-
if (denied) return denied;
65-
66-
// API-key callers already possess a valid environment credential. Reuse
67-
// exactly what they presented instead of exchanging it for the root key.
68-
const presentedApiKey = presentedApiKeyFromAuthentication(authenticationResult);
49+
// env-tier read:apiKeys. A machine credential never receives that root key.
50+
if (authenticationResult.type !== "apiKey") {
51+
const denied = await authorizePatEnvironmentAccess({
52+
request,
53+
authType: authenticationResult.type,
54+
organizationId: environment.organizationId,
55+
projectId: environment.project.id,
56+
envType: environment.type,
57+
resource: "apiKeys",
58+
action: "read",
59+
});
60+
if (denied) return denied;
61+
}
6962

7063
const result: GetProjectEnvResponse = {
71-
apiKey: presentedApiKey ?? environment.apiKey,
64+
apiKey: apiKeyForProjectEnvironmentBootstrap(authenticationResult, environment.apiKey),
7265
name: environment.project.name,
7366
apiUrl: processEnv.API_ORIGIN ?? processEnv.APP_ORIGIN,
7467
projectId: environment.project.id,

apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts

Lines changed: 92 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3";
33
import { DEFAULT_DEV_BRANCH, isDefaultDevBranch } from "@trigger.dev/core/v3/utils/gitBranch";
44
import { z } from "zod";
55
import { prisma } from "~/db.server";
6-
import { authenticateRequest } from "~/services/apiAuth.server";
6+
import {
7+
authenticateApiKeyWithScope,
8+
authenticateRequest,
9+
type AuthenticationResult,
10+
} from "~/services/apiAuth.server";
711
import { logger } from "~/services/logger.server";
812
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
913
import { UpsertBranchService } from "~/services/upsertBranch.server";
@@ -21,15 +25,35 @@ export async function action({ request, params }: ActionFunctionArgs) {
2125

2226
logger.info("project upsert branch", { url: request.url });
2327

24-
const authenticationResult = await authenticateRequest(request, {
28+
const userOrOrganizationAuthentication = await authenticateRequest(request, {
2529
personalAccessToken: true,
2630
organizationAccessToken: true,
2731
apiKey: false,
2832
});
29-
if (!authenticationResult) {
30-
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
33+
34+
let authenticationResult: AuthenticationResult;
35+
if (userOrOrganizationAuthentication) {
36+
authenticationResult = userOrOrganizationAuthentication;
37+
} else {
38+
const apiKeyAuthentication = await authenticateApiKeyWithScope(request, {
39+
action: "write",
40+
resource: { type: "branches" },
41+
allowPreviewParent: true,
42+
});
43+
if (!apiKeyAuthentication.ok) {
44+
return json({ error: apiKeyAuthentication.error }, { status: apiKeyAuthentication.status });
45+
}
46+
authenticationResult = {
47+
type: "apiKey",
48+
result: apiKeyAuthentication.authentication,
49+
};
3150
}
3251

52+
const apiKeyEnvironment =
53+
authenticationResult.type === "apiKey" && authenticationResult.result.ok
54+
? authenticationResult.result.environment
55+
: undefined;
56+
3357
const parsedParams = ParamsSchema.safeParse(params);
3458

3559
if (!parsedParams.success) {
@@ -38,24 +62,32 @@ export async function action({ request, params }: ActionFunctionArgs) {
3862

3963
const { projectRef } = parsedParams.data;
4064

41-
const project = await prisma.project.findFirst({
42-
select: {
43-
id: true,
44-
},
45-
where: {
46-
externalRef: projectRef,
47-
organization:
48-
authenticationResult.type === "organizationAccessToken"
49-
? { id: authenticationResult.result.organizationId }
50-
: {
51-
members: {
52-
some: {
53-
userId: authenticationResult.result.userId,
65+
let project: { id: string } | null | undefined;
66+
if (authenticationResult.type === "apiKey") {
67+
project =
68+
apiKeyEnvironment?.project.externalRef === projectRef
69+
? { id: apiKeyEnvironment.project.id }
70+
: undefined;
71+
} else {
72+
project = await prisma.project.findFirst({
73+
select: {
74+
id: true,
75+
},
76+
where: {
77+
externalRef: projectRef,
78+
organization:
79+
authenticationResult.type === "organizationAccessToken"
80+
? { id: authenticationResult.result.organizationId }
81+
: {
82+
members: {
83+
some: {
84+
userId: authenticationResult.result.userId,
85+
},
5486
},
5587
},
56-
},
57-
},
58-
});
88+
},
89+
});
90+
}
5991
if (!project) {
6092
return json({ error: "Project not found" }, { status: 404 });
6193
}
@@ -72,38 +104,64 @@ export async function action({ request, params }: ActionFunctionArgs) {
72104

73105
const { branch, env, git } = parsed.data;
74106

75-
if (env === "development" && authenticationResult.type === "organizationAccessToken") {
107+
if (env === "development" && authenticationResult.type !== "personalAccessToken") {
76108
return json(
77-
{ error: "Cannot create dev branches with organization access tokens." },
109+
{
110+
error:
111+
authenticationResult.type === "apiKey"
112+
? "API keys can only create Preview branches."
113+
: "Cannot create dev branches with organization access tokens.",
114+
},
78115
{ status: 400 }
79116
);
80117
}
81118

119+
if (
120+
authenticationResult.type === "apiKey" &&
121+
(!apiKeyEnvironment ||
122+
apiKeyEnvironment.type !== "PREVIEW" ||
123+
apiKeyEnvironment.parentEnvironmentId !== null)
124+
) {
125+
return json(
126+
{ error: "API keys must belong to the parent Preview environment." },
127+
{ status: 403 }
128+
);
129+
}
130+
82131
if (env === "development" && isDefaultDevBranch(branch)) {
83132
return json(
84133
{ error: `Cannot create dev branch with name '${DEFAULT_DEV_BRANCH}'.` },
85134
{ status: 400 }
86135
);
87136
}
88137

89-
const service = new UpsertBranchService();
90-
const result = await service.call(
91-
authenticationResult.type === "organizationAccessToken"
92-
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
93-
: { type: "userMembership", userId: authenticationResult.result.userId },
94-
{
95-
env,
96-
branchName: branch,
97-
projectId: project.id,
98-
git,
138+
let orgFilter:
139+
| { type: "userMembership"; userId: string }
140+
| { type: "orgId"; organizationId: string };
141+
if (authenticationResult.type === "personalAccessToken") {
142+
orgFilter = { type: "userMembership", userId: authenticationResult.result.userId };
143+
} else if (authenticationResult.type === "organizationAccessToken") {
144+
orgFilter = { type: "orgId", organizationId: authenticationResult.result.organizationId };
145+
} else {
146+
if (!apiKeyEnvironment) {
147+
return json({ error: "Invalid API key" }, { status: 401 });
99148
}
100-
);
149+
orgFilter = { type: "orgId", organizationId: apiKeyEnvironment.organizationId };
150+
}
151+
152+
const service = new UpsertBranchService();
153+
const result = await service.call(orgFilter, {
154+
env,
155+
branchName: branch,
156+
projectId: project.id,
157+
git,
158+
});
101159

102160
if (!result.success) {
103161
return json({ error: result.error }, { status: 400 });
104162
}
105163

106-
return json(result.branch);
164+
return json({ id: result.branch.id });
107165
}
108166

109167
export async function loader({ request, params }: LoaderFunctionArgs) {

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

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
findEnvironmentByPublicApiKey,
1414
toAuthenticated,
1515
} from "~/models/runtimeEnvironment.server";
16-
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
16+
import type { BearerAuthOptions, RbacAbility, RbacResource } from "@trigger.dev/rbac";
1717
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
1818
import { logger } from "./logger.server";
1919
import { safeEnvironmentLogFields } from "./safeEnvironmentLog";
@@ -32,6 +32,7 @@ import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
3232
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
3333
import {
3434
authenticateAuthorizeBearerWithTelemetry,
35+
authenticateBearerWithTelemetry,
3536
observeLegacyBearerAuthentication,
3637
} from "~/services/authTelemetry.server";
3738

@@ -284,6 +285,37 @@ async function authenticateApiKeyWithFailure(
284285
}
285286
}
286287

288+
/** Authenticate a private API-key request without requiring a resource scope. */
289+
export async function authenticateApiKeyRequest(
290+
request: Request,
291+
options: BearerAuthOptions = {},
292+
authenticateBearer: typeof authenticateBearerWithTelemetry = authenticateBearerWithTelemetry
293+
): Promise<
294+
| { ok: true; authentication: ApiAuthenticationResultSuccess }
295+
| { ok: false; status: 401 | 403; error: string }
296+
> {
297+
const apiKey = getApiKeyFromHeader(request.headers.get("Authorization"));
298+
if (!apiKey) {
299+
return { ok: false, status: 401, error: "Invalid or Missing API key" };
300+
}
301+
302+
const result = await authenticateBearer(request, options);
303+
if (!result.ok) {
304+
return result;
305+
}
306+
307+
return {
308+
ok: true,
309+
authentication: {
310+
ok: true,
311+
apiKey,
312+
type: "PRIVATE",
313+
environment: result.environment,
314+
ability: result.ability,
315+
},
316+
};
317+
}
318+
287319
/**
288320
* Authenticate an API-key request for a legacy (non-apiBuilder) route that
289321
* needs to accept granular additional keys, then enforce that the key's ability
@@ -299,7 +331,13 @@ export async function authenticateApiKeyWithScope(
299331
action,
300332
resource,
301333
allowJWT = false,
302-
}: { action: string; resource: RbacResource; allowJWT?: boolean },
334+
allowPreviewParent = false,
335+
}: {
336+
action: string;
337+
resource: RbacResource;
338+
allowJWT?: boolean;
339+
allowPreviewParent?: boolean;
340+
},
303341
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
304342
): Promise<
305343
| { ok: true; authentication: ApiAuthenticationResultSuccess }
@@ -310,7 +348,11 @@ export async function authenticateApiKeyWithScope(
310348
return { ok: false, status: 401, error: "Invalid or Missing API key" };
311349
}
312350

313-
const result = await authorizeBearer(request, { action, resource }, { allowJWT });
351+
const result = await authorizeBearer(
352+
request,
353+
{ action, resource },
354+
{ allowJWT, allowPreviewParent }
355+
);
314356
if (!result.ok) {
315357
return result;
316358
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getMeter } from "@internal/tracing";
22
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
33
import { isPublicJWT } from "@trigger.dev/core/v3/jwt";
4+
import type { BearerAuthOptions } from "@trigger.dev/plugins";
45
import type {
56
BearerCredentialKind,
67
BearerLookupPath,
@@ -39,10 +40,10 @@ const telemetry = singleton("apiAuthTelemetry", () => {
3940

4041
export async function authenticateBearerWithTelemetry(
4142
request: Request,
42-
options: { allowJWT: boolean }
43+
options: BearerAuthOptions
4344
): Promise<HostBearerAuthResult> {
4445
const startedAt = performance.now();
45-
const classified = classifyCredential(request, options.allowJWT);
46+
const classified = classifyCredential(request, options.allowJWT ?? false);
4647
let final = { ...classified, result: "error" as ApiAuthResult };
4748

4849
try {
@@ -79,7 +80,7 @@ export async function authenticateBearerWithTelemetry(
7980
export async function authenticateAuthorizeBearerWithTelemetry(
8081
request: Request,
8182
check: { action: string; resource: RbacResource },
82-
options: { allowJWT: boolean }
83+
options: BearerAuthOptions
8384
) {
8485
// Keep authentication telemetry consistent with apiBuilder: a valid
8586
// credential records a successful authentication even when the subsequent

0 commit comments

Comments
 (0)