Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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_ACCESS_TOKEN`.
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,26 +64,52 @@ 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.

// API keys can only act on their own project
if (
authenticationResult.type === "apiKey" &&
apiKeyEnvironment?.project.externalRef !== projectRef
) {
return json({ error: "Project not found" }, { status: 404 });
}

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" &&
environmentType === "DEVELOPMENT"
...(authenticationResult.type === "personalAccessToken" && environmentType === "DEVELOPMENT"
? { orgMember: { userId: authenticationResult.result.userId } }
: {}),
project: {
Expand All @@ -91,7 +127,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 +146,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
Loading
Loading