Skip to content

Commit 1360422

Browse files
committed
feat(cli,webapp): allow deploys with environment API keys
1 parent 4569657 commit 1360422

20 files changed

Lines changed: 676 additions & 130 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) {

0 commit comments

Comments
 (0)