Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deploy-api-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---

Allow `trigger deploy` to authenticate with an environment API key from `TRIGGER_SECRET_KEY`, including deploy-only keys and Preview deployments.
Original file line number Diff line number Diff line change
Expand Up @@ -872,7 +872,7 @@ const API_KEY_EXPIRATIONS = [
{ value: "never", label: "Never" },
];

type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "envvars";
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "branches" | "envvars";

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

Expand Down Expand Up @@ -918,6 +919,7 @@ const SCOPE_CAPABILITY_BY_SCOPE: Record<string, [CapId, number]> = {
"write:queues": ["queues", 2],
"read:deployments": ["deployments", 1],
"write:deployments": ["deployments", 2],
"write:branches": ["branches", 3],
"read:envvars": ["envvars", 1],
"write:envvars": ["envvars", 2],
};
Expand Down
45 changes: 19 additions & 26 deletions apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
} from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import {
authenticateEnvironmentScopedApiRequest,
apiKeyForProjectEnvironmentBootstrap,
authenticateEnvironmentBootstrapRequest,
authorizePatEnvironmentAccess,
presentedApiKeyFromAuthentication,
} from "~/services/environmentVariableApiAccess.server";

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

try {
// PAT/OAT authenticate on the legacy path; machine API keys go through
// the RBAC controller so additional keys (and their grants) are enforced.
const authResult = await authenticateEnvironmentScopedApiRequest(request, "read", "apiKeys");
// PAT/OAT authenticate on the legacy path; machine API keys only need to
// prove they are valid because bootstrap echoes the same key back.
const authResult = await authenticateEnvironmentBootstrapRequest(request);
Comment thread
carderne marked this conversation as resolved.
if (!authResult.ok) {
return json({ error: authResult.error }, { status: authResult.status });
}
Expand All @@ -46,29 +46,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
);

// User tokens bootstrap the environment's secret key, so gate them on
// env-tier read:apiKeys. Machine credentials are checked against the same
// permission before their presented key is returned below.
const denied = await authorizePatEnvironmentAccess({
request,
authType: authenticationResult.type,
ability:
authenticationResult.type === "apiKey" && authenticationResult.result.ok
? authenticationResult.result.ability
: undefined,
organizationId: environment.organizationId,
projectId: environment.project.id,
envType: environment.type,
resource: "apiKeys",
action: "read",
});
if (denied) return denied;

// API-key callers already possess a valid environment credential. Reuse
// exactly what they presented instead of exchanging it for the root key.
const presentedApiKey = presentedApiKeyFromAuthentication(authenticationResult);
// env-tier read:apiKeys. A machine credential never receives that root key.
if (authenticationResult.type !== "apiKey") {
const denied = await authorizePatEnvironmentAccess({
request,
authType: authenticationResult.type,
organizationId: environment.organizationId,
projectId: environment.project.id,
envType: environment.type,
resource: "apiKeys",
action: "read",
});
if (denied) return denied;
}
Comment thread
carderne marked this conversation as resolved.

const result: GetProjectEnvResponse = {
apiKey: presentedApiKey ?? environment.apiKey,
apiKey: apiKeyForProjectEnvironmentBootstrap(authenticationResult, environment.apiKey),
name: environment.project.name,
apiUrl: processEnv.API_ORIGIN ?? processEnv.APP_ORIGIN,
projectId: environment.project.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateRequest } from "~/services/apiAuth.server";
import { authenticateRequestWithScopedApiKey } from "~/services/apiAuth.server";
import { ArchiveBranchService } from "~/services/archiveBranch.server";
import { logger } from "~/services/logger.server";
import { toBranchableEnvironmentType } from "~/utils/branchableEnvironment";
Expand All @@ -24,15 +24,25 @@ export async function action({ request, params }: ActionFunctionArgs) {

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

const authenticationResult = await authenticateRequest(request, {
const authentication = await authenticateRequestWithScopedApiKey(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
apiKey: {
action: "write",
resource: { type: "branches" },
allowPreviewParent: true,
},
});

if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
if (!authentication.ok) {
return json({ error: authentication.error }, { status: authentication.status });
}
const authenticationResult = authentication.authentication;

const apiKeyEnvironment =
authenticationResult.type === "apiKey" && authenticationResult.result.ok
? authenticationResult.result.environment
: undefined;

const parsedParams = ParamsSchema.safeParse(params);

Expand All @@ -54,25 +64,44 @@ export async function action({ request, params }: ActionFunctionArgs) {

const { env, branch } = parsed.data;

// API keys can only archive Preview branches
if (
authenticationResult.type === "apiKey" &&
(!apiKeyEnvironment ||
apiKeyEnvironment.type !== "PREVIEW" ||
apiKeyEnvironment.parentEnvironmentId !== null ||
env !== "preview")
) {
return json(
{ error: "API keys must belong to the parent Preview environment." },
{ status: 403 }
);
}
Comment thread
carderne marked this conversation as resolved.

const environmentType = toBranchableEnvironmentType(env);

const organizationFilter =
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: authenticationResult.type === "apiKey"
? { id: apiKeyEnvironment!.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
},
},
};
Comment thread
carderne marked this conversation as resolved.

const environments = await prisma.runtimeEnvironment.findMany({
select: {
id: true,
archivedAt: true,
},
where: {
organization:
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
},
},
},
organization: organizationFilter,
// Dev branches are per-org-member: only the owner may archive their own.
...(authenticationResult.type !== "organizationAccessToken" &&
...(authenticationResult.type === "personalAccessToken" &&
environmentType === "DEVELOPMENT"
? { orgMember: { userId: authenticationResult.result.userId } }
: {}),
Expand All @@ -91,7 +120,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
const activeEnvironments = environments.filter((env) => env.archivedAt === null);

if (
authenticationResult.type === "organizationAccessToken" &&
authenticationResult.type !== "personalAccessToken" &&
environmentType === "DEVELOPMENT" &&
activeEnvironments.length > 1
) {
Expand All @@ -110,15 +139,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Branch already archived" }, { status: 400 });
}

let orgFilter:
| { type: "userMembership"; userId: string }
| { type: "orgId"; organizationId: string };
if (authenticationResult.type === "personalAccessToken") {
orgFilter = { type: "userMembership", userId: authenticationResult.result.userId };
} else if (authenticationResult.type === "organizationAccessToken") {
orgFilter = { type: "orgId", organizationId: authenticationResult.result.organizationId };
} else {
orgFilter = { type: "orgId", organizationId: apiKeyEnvironment!.organizationId };
}

const service = new ArchiveBranchService();
const result = await service.call(
authenticationResult.type === "organizationAccessToken"
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
: { type: "userMembership", userId: authenticationResult.result.userId },
{
environmentId: environment.id,
}
);
const result = await service.call(orgFilter, {
environmentId: environment.id,
});

if (result.success) {
return json(result);
Expand Down
114 changes: 79 additions & 35 deletions apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3";
import { DEFAULT_DEV_BRANCH, isDefaultDevBranch } from "@trigger.dev/core/v3/utils/gitBranch";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateRequest } from "~/services/apiAuth.server";
import { authenticateRequestWithScopedApiKey } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { UpsertBranchService } from "~/services/upsertBranch.server";
Expand All @@ -21,14 +21,24 @@ export async function action({ request, params }: ActionFunctionArgs) {

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

const authenticationResult = await authenticateRequest(request, {
const authentication = await authenticateRequestWithScopedApiKey(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
apiKey: {
action: "write",
resource: { type: "branches" },
allowPreviewParent: true,
},
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
if (!authentication.ok) {
return json({ error: authentication.error }, { status: authentication.status });
}
const authenticationResult = authentication.authentication;

const apiKeyEnvironment =
authenticationResult.type === "apiKey" && authenticationResult.result.ok
? authenticationResult.result.environment
: undefined;

const parsedParams = ParamsSchema.safeParse(params);

Expand All @@ -38,24 +48,32 @@ export async function action({ request, params }: ActionFunctionArgs) {

const { projectRef } = parsedParams.data;

const project = await prisma.project.findFirst({
select: {
id: true,
},
where: {
externalRef: projectRef,
organization:
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
let project: { id: string } | null | undefined;
if (authenticationResult.type === "apiKey") {
project =
apiKeyEnvironment?.project.externalRef === projectRef
? { id: apiKeyEnvironment.project.id }
: undefined;
} else {
project = await prisma.project.findFirst({
select: {
id: true,
},
where: {
externalRef: projectRef,
organization:
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
},
},
},
},
},
});
},
});
}
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
Expand All @@ -72,38 +90,64 @@ export async function action({ request, params }: ActionFunctionArgs) {

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

if (env === "development" && authenticationResult.type === "organizationAccessToken") {
if (env === "development" && authenticationResult.type !== "personalAccessToken") {
return json(
{ error: "Cannot create dev branches with organization access tokens." },
{
error:
authenticationResult.type === "apiKey"
? "API keys can only create Preview branches."
: "Cannot create dev branches with organization access tokens.",
},
{ status: 400 }
);
}

if (
authenticationResult.type === "apiKey" &&
(!apiKeyEnvironment ||
apiKeyEnvironment.type !== "PREVIEW" ||
apiKeyEnvironment.parentEnvironmentId !== null)
) {
return json(
{ error: "API keys must belong to the parent Preview environment." },
{ status: 403 }
);
}

if (env === "development" && isDefaultDevBranch(branch)) {
return json(
{ error: `Cannot create dev branch with name '${DEFAULT_DEV_BRANCH}'.` },
{ status: 400 }
);
}

const service = new UpsertBranchService();
const result = await service.call(
authenticationResult.type === "organizationAccessToken"
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
: { type: "userMembership", userId: authenticationResult.result.userId },
{
env,
branchName: branch,
projectId: project.id,
git,
let orgFilter:
| { type: "userMembership"; userId: string }
| { type: "orgId"; organizationId: string };
if (authenticationResult.type === "personalAccessToken") {
orgFilter = { type: "userMembership", userId: authenticationResult.result.userId };
} else if (authenticationResult.type === "organizationAccessToken") {
orgFilter = { type: "orgId", organizationId: authenticationResult.result.organizationId };
} else {
if (!apiKeyEnvironment) {
return json({ error: "Invalid API key" }, { status: 401 });
}
);
orgFilter = { type: "orgId", organizationId: apiKeyEnvironment.organizationId };
}

const service = new UpsertBranchService();
const result = await service.call(orgFilter, {
env,
branchName: branch,
projectId: project.id,
git,
});

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

return json(result.branch);
return json({ id: result.branch.id });
}

export async function loader({ request, params }: LoaderFunctionArgs) {
Expand Down
Loading
Loading