Skip to content

Commit 7b390e5

Browse files
authored
feat(cli,webapp): allow deploys with environment API keys (#4561)
1 parent 26a730f commit 7b390e5

21 files changed

Lines changed: 922 additions & 141 deletions

.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_ACCESS_TOKEN`.

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
@@ -874,7 +874,7 @@ const API_KEY_EXPIRATIONS = [
874874
{ value: "never", label: "Never" },
875875
];
876876

877-
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "envvars";
877+
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "branches" | "envvars";
878878

879879
// Capability rows shown in the scope pane, in a fixed order so two presets read
880880
// as a diff of the same list rather than a reshuffled one.
@@ -884,6 +884,7 @@ const SCOPE_CAPABILITIES: [CapId, string][] = [
884884
["batches", "Batches"],
885885
["queues", "Queues"],
886886
["deployments", "Deployments"],
887+
["branches", "Preview branches"],
887888
["envvars", "Environment variables"],
888889
];
889890

@@ -920,6 +921,7 @@ const SCOPE_CAPABILITY_BY_SCOPE: Record<string, [CapId, number]> = {
920921
"write:queues": ["queues", 2],
921922
"read:deployments": ["deployments", 1],
922923
"write:deployments": ["deployments", 2],
924+
"write:branches": ["branches", 3],
923925
"read:envvars": ["envvars", 1],
924926
"write:envvars": ["envvars", 2],
925927
};

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.archive.ts

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { tryCatch } from "@trigger.dev/core";
33
import { z } from "zod";
44
import { prisma } from "~/db.server";
5-
import { authenticateRequest } from "~/services/apiAuth.server";
5+
import { authenticateRequestWithScopedApiKey } from "~/services/apiAuth.server";
66
import { ArchiveBranchService } from "~/services/archiveBranch.server";
77
import { logger } from "~/services/logger.server";
88
import { toBranchableEnvironmentType } from "~/utils/branchableEnvironment";
@@ -24,15 +24,25 @@ export async function action({ request, params }: ActionFunctionArgs) {
2424

2525
logger.info("Archive branch", { url: request.url, params });
2626

27-
const authenticationResult = await authenticateRequest(request, {
27+
const authentication = await authenticateRequestWithScopedApiKey(request, {
2828
personalAccessToken: true,
2929
organizationAccessToken: true,
30-
apiKey: false,
30+
apiKey: {
31+
action: "write",
32+
resource: { type: "branches" },
33+
allowPreviewParent: true,
34+
},
3135
});
3236

33-
if (!authenticationResult) {
34-
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
37+
if (!authentication.ok) {
38+
return json({ error: authentication.error }, { status: authentication.status });
3539
}
40+
const authenticationResult = authentication.authentication;
41+
42+
const apiKeyEnvironment =
43+
authenticationResult.type === "apiKey" && authenticationResult.result.ok
44+
? authenticationResult.result.environment
45+
: undefined;
3646

3747
const parsedParams = ParamsSchema.safeParse(params);
3848

@@ -54,26 +64,52 @@ export async function action({ request, params }: ActionFunctionArgs) {
5464

5565
const { env, branch } = parsed.data;
5666

67+
// API keys can only archive Preview branches
68+
if (
69+
authenticationResult.type === "apiKey" &&
70+
(!apiKeyEnvironment ||
71+
apiKeyEnvironment.type !== "PREVIEW" ||
72+
apiKeyEnvironment.parentEnvironmentId !== null ||
73+
env !== "preview")
74+
) {
75+
return json(
76+
{ error: "API keys must belong to the parent Preview environment." },
77+
{ status: 403 }
78+
);
79+
}
80+
81+
// API keys can only act on their own project
82+
if (
83+
authenticationResult.type === "apiKey" &&
84+
apiKeyEnvironment?.project.externalRef !== projectRef
85+
) {
86+
return json({ error: "Project not found" }, { status: 404 });
87+
}
88+
5789
const environmentType = toBranchableEnvironmentType(env);
90+
91+
const organizationFilter =
92+
authenticationResult.type === "organizationAccessToken"
93+
? { id: authenticationResult.result.organizationId }
94+
: authenticationResult.type === "apiKey"
95+
? { id: apiKeyEnvironment!.organizationId }
96+
: {
97+
members: {
98+
some: {
99+
userId: authenticationResult.result.userId,
100+
},
101+
},
102+
};
103+
58104
const environments = await prisma.runtimeEnvironment.findMany({
59105
select: {
60106
id: true,
61107
archivedAt: true,
62108
},
63109
where: {
64-
organization:
65-
authenticationResult.type === "organizationAccessToken"
66-
? { id: authenticationResult.result.organizationId }
67-
: {
68-
members: {
69-
some: {
70-
userId: authenticationResult.result.userId,
71-
},
72-
},
73-
},
110+
organization: organizationFilter,
74111
// Dev branches are per-org-member: only the owner may archive their own.
75-
...(authenticationResult.type !== "organizationAccessToken" &&
76-
environmentType === "DEVELOPMENT"
112+
...(authenticationResult.type === "personalAccessToken" && environmentType === "DEVELOPMENT"
77113
? { orgMember: { userId: authenticationResult.result.userId } }
78114
: {}),
79115
project: {
@@ -91,7 +127,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
91127
const activeEnvironments = environments.filter((env) => env.archivedAt === null);
92128

93129
if (
94-
authenticationResult.type === "organizationAccessToken" &&
130+
authenticationResult.type !== "personalAccessToken" &&
95131
environmentType === "DEVELOPMENT" &&
96132
activeEnvironments.length > 1
97133
) {
@@ -110,15 +146,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
110146
return json({ error: "Branch already archived" }, { status: 400 });
111147
}
112148

149+
let orgFilter:
150+
| { type: "userMembership"; userId: string }
151+
| { type: "orgId"; organizationId: string };
152+
if (authenticationResult.type === "personalAccessToken") {
153+
orgFilter = { type: "userMembership", userId: authenticationResult.result.userId };
154+
} else if (authenticationResult.type === "organizationAccessToken") {
155+
orgFilter = { type: "orgId", organizationId: authenticationResult.result.organizationId };
156+
} else {
157+
orgFilter = { type: "orgId", organizationId: apiKeyEnvironment!.organizationId };
158+
}
159+
113160
const service = new ArchiveBranchService();
114-
const result = await service.call(
115-
authenticationResult.type === "organizationAccessToken"
116-
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
117-
: { type: "userMembership", userId: authenticationResult.result.userId },
118-
{
119-
environmentId: environment.id,
120-
}
121-
);
161+
const result = await service.call(orgFilter, {
162+
environmentId: environment.id,
163+
});
122164

123165
if (result.success) {
124166
return json(result);

0 commit comments

Comments
 (0)