Skip to content
Draft
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_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);
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;
}

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
126 changes: 92 additions & 34 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,11 @@ 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 {
authenticateApiKeyWithScope,
authenticateRequest,
type AuthenticationResult,
} 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,15 +25,35 @@ export async function action({ request, params }: ActionFunctionArgs) {

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

const authenticationResult = await authenticateRequest(request, {
const userOrOrganizationAuthentication = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });

let authenticationResult: AuthenticationResult;
if (userOrOrganizationAuthentication) {
authenticationResult = userOrOrganizationAuthentication;
} else {
const apiKeyAuthentication = await authenticateApiKeyWithScope(request, {
action: "write",
resource: { type: "branches" },
allowPreviewParent: true,
});
if (!apiKeyAuthentication.ok) {
return json({ error: apiKeyAuthentication.error }, { status: apiKeyAuthentication.status });
}
authenticationResult = {
type: "apiKey",
result: apiKeyAuthentication.authentication,
};
}

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

const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
Expand All @@ -38,24 +62,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 +104,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
48 changes: 45 additions & 3 deletions apps/webapp/app/services/apiAuth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
findEnvironmentByPublicApiKey,
toAuthenticated,
} from "~/models/runtimeEnvironment.server";
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
import type { BearerAuthOptions, RbacAbility, RbacResource } from "@trigger.dev/rbac";
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { logger } from "./logger.server";
import { safeEnvironmentLogFields } from "./safeEnvironmentLog";
Expand All @@ -32,6 +32,7 @@ import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import {
authenticateAuthorizeBearerWithTelemetry,
authenticateBearerWithTelemetry,
observeLegacyBearerAuthentication,
} from "~/services/authTelemetry.server";

Expand Down Expand Up @@ -284,6 +285,37 @@ async function authenticateApiKeyWithFailure(
}
}

/** Authenticate a private API-key request without requiring a resource scope. */
export async function authenticateApiKeyRequest(
request: Request,
options: BearerAuthOptions = {},
authenticateBearer: typeof authenticateBearerWithTelemetry = authenticateBearerWithTelemetry
): Promise<
| { ok: true; authentication: ApiAuthenticationResultSuccess }
| { ok: false; status: 401 | 403; error: string }
> {
const apiKey = getApiKeyFromHeader(request.headers.get("Authorization"));
if (!apiKey) {
return { ok: false, status: 401, error: "Invalid or Missing API key" };
}

const result = await authenticateBearer(request, options);
if (!result.ok) {
return result;
}

return {
ok: true,
authentication: {
ok: true,
apiKey,
type: "PRIVATE",
environment: result.environment,
ability: result.ability,
},
};
}

/**
* Authenticate an API-key request for a legacy (non-apiBuilder) route that
* needs to accept granular additional keys, then enforce that the key's ability
Expand All @@ -299,7 +331,13 @@ export async function authenticateApiKeyWithScope(
action,
resource,
allowJWT = false,
}: { action: string; resource: RbacResource; allowJWT?: boolean },
allowPreviewParent = false,
}: {
action: string;
resource: RbacResource;
allowJWT?: boolean;
allowPreviewParent?: boolean;
},
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
): Promise<
| { ok: true; authentication: ApiAuthenticationResultSuccess }
Expand All @@ -310,7 +348,11 @@ export async function authenticateApiKeyWithScope(
return { ok: false, status: 401, error: "Invalid or Missing API key" };
}

const result = await authorizeBearer(request, { action, resource }, { allowJWT });
const result = await authorizeBearer(
request,
{ action, resource },
{ allowJWT, allowPreviewParent }
);
if (!result.ok) {
return result;
}
Expand Down
7 changes: 4 additions & 3 deletions apps/webapp/app/services/authTelemetry.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getMeter } from "@internal/tracing";
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
import { isPublicJWT } from "@trigger.dev/core/v3/jwt";
import type { BearerAuthOptions } from "@trigger.dev/plugins";
import type {
BearerCredentialKind,
BearerLookupPath,
Expand Down Expand Up @@ -39,10 +40,10 @@ const telemetry = singleton("apiAuthTelemetry", () => {

export async function authenticateBearerWithTelemetry(
request: Request,
options: { allowJWT: boolean }
options: BearerAuthOptions
): Promise<HostBearerAuthResult> {
const startedAt = performance.now();
const classified = classifyCredential(request, options.allowJWT);
const classified = classifyCredential(request, options.allowJWT ?? false);
let final = { ...classified, result: "error" as ApiAuthResult };

try {
Expand Down Expand Up @@ -79,7 +80,7 @@ export async function authenticateBearerWithTelemetry(
export async function authenticateAuthorizeBearerWithTelemetry(
request: Request,
check: { action: string; resource: RbacResource },
options: { allowJWT: boolean }
options: BearerAuthOptions
) {
// Keep authentication telemetry consistent with apiBuilder: a valid
// credential records a successful authentication even when the subsequent
Expand Down
Loading
Loading