diff --git a/.changeset/deploy-api-keys.md b/.changeset/deploy-api-keys.md new file mode 100644 index 00000000000..88bc8e69a5b --- /dev/null +++ b/.changeset/deploy-api-keys.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Allow `trigger deploy` to authenticate with an environment API key from `TRIGGER_ACCESS_TOKEN`. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index b33e6756ab8..b5fbd2b41ee 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -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. @@ -882,6 +882,7 @@ const SCOPE_CAPABILITIES: [CapId, string][] = [ ["batches", "Batches"], ["queues", "Queues"], ["deployments", "Deployments"], + ["branches", "Preview branches"], ["envvars", "Environment variables"], ]; @@ -918,6 +919,7 @@ const SCOPE_CAPABILITY_BY_SCOPE: Record = { "write:queues": ["queues", 2], "read:deployments": ["deployments", 1], "write:deployments": ["deployments", 2], + "write:branches": ["branches", 3], "read:envvars": ["envvars", 1], "write:envvars": ["envvars", 2], }; diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts index 5c01c375601..c7090ce4721 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts @@ -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({ @@ -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 }); } @@ -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, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts index 1cc565f95ec..30a3ae6471a 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts @@ -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"; @@ -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); @@ -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 } + ); + } + + // 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, + }, + }, + }; + 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: { @@ -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 ) { @@ -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); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts index 8ab57c63431..91e1e90a468 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts @@ -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"; @@ -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); @@ -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 }); } @@ -72,13 +90,30 @@ 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}'.` }, @@ -86,24 +121,33 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } - 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) { diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 4c05f9e4b37..d0b6449b820 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -13,7 +13,12 @@ import { findEnvironmentByPublicApiKey, toAuthenticated, } from "~/models/runtimeEnvironment.server"; -import type { RbacAbility, RbacResource, UserActorClaims } from "@trigger.dev/rbac"; +import type { + BearerAuthOptions, + RbacAbility, + RbacResource, + UserActorClaims, +} from "@trigger.dev/rbac"; import { assertUserActorEnvironment } from "./userActorEnvironment.server"; import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { logger } from "./logger.server"; @@ -33,6 +38,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"; @@ -295,6 +301,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 @@ -304,13 +341,16 @@ async function authenticateApiKeyWithFailure( * Only apiKey credentials are accepted (no PAT / org token / public key). Use * this for routes previously guarded by a bare `authenticateApiRequest` call. */ +export type ApiKeyScopeAuthorization = { + action: string; + resource: RbacResource; + allowJWT?: boolean; + allowPreviewParent?: boolean; +}; + export async function authenticateApiKeyWithScope( request: Request, - { - action, - resource, - allowJWT = false, - }: { action: string; resource: RbacResource; allowJWT?: boolean }, + { action, resource, allowJWT = false, allowPreviewParent = false }: ApiKeyScopeAuthorization, authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry ): Promise< | { ok: true; authentication: ApiAuthenticationResultSuccess } @@ -321,7 +361,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; } @@ -338,6 +382,50 @@ export async function authenticateApiKeyWithScope( }; } +export type ScopedApiKeyAuthenticationDependencies = { + authenticateRequest: typeof authenticateRequest; + authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope; +}; + +export async function authenticateRequestWithScopedApiKey( + request: Request, + { + personalAccessToken, + organizationAccessToken, + apiKey, + }: { + personalAccessToken: true; + organizationAccessToken: true; + apiKey: ApiKeyScopeAuthorization; + }, + dependencies: ScopedApiKeyAuthenticationDependencies = { + authenticateRequest, + authenticateApiKeyWithScope, + } +): Promise< + | { ok: true; authentication: AuthenticationResult } + | { ok: false; status: 401 | 403; error: string } +> { + const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, { + personalAccessToken, + organizationAccessToken, + apiKey: false, + }); + if (userOrOrganizationAuthentication) { + return { ok: true, authentication: userOrOrganizationAuthentication }; + } + + const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, apiKey); + if (!apiKeyAuthentication.ok) { + return apiKeyAuthentication; + } + + return { + ok: true, + authentication: { type: "apiKey", result: apiKeyAuthentication.authentication }, + }; +} + export async function authenticateAuthorizationHeader( authorization: string, { diff --git a/apps/webapp/app/services/authTelemetry.server.ts b/apps/webapp/app/services/authTelemetry.server.ts index 378fe4e7606..65a545545ea 100644 --- a/apps/webapp/app/services/authTelemetry.server.ts +++ b/apps/webapp/app/services/authTelemetry.server.ts @@ -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, @@ -39,10 +40,10 @@ const telemetry = singleton("apiAuthTelemetry", () => { export async function authenticateBearerWithTelemetry( request: Request, - options: { allowJWT: boolean } + options: BearerAuthOptions ): Promise { 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 { @@ -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 diff --git a/apps/webapp/app/services/environmentVariableApiAccess.server.ts b/apps/webapp/app/services/environmentVariableApiAccess.server.ts index 2b32cd0a886..47e769d7b69 100644 --- a/apps/webapp/app/services/environmentVariableApiAccess.server.ts +++ b/apps/webapp/app/services/environmentVariableApiAccess.server.ts @@ -3,9 +3,11 @@ import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { isUserActorToken } from "@trigger.dev/rbac"; import type { RbacAbility } from "@trigger.dev/rbac"; import { - authenticateApiKeyWithScope, + authenticateApiKeyRequest, authenticateRequest, + authenticateRequestWithScopedApiKey, type AuthenticationResult, + type ScopedApiKeyAuthenticationDependencies, } from "~/services/apiAuth.server"; import { rbac } from "~/services/rbac.server"; @@ -28,20 +30,52 @@ export function presentedApiKeyFromAuthentication( : undefined; } +export function apiKeyForProjectEnvironmentBootstrap( + authentication: AuthenticationResult, + rootApiKey: string +): string { + return presentedApiKeyFromAuthentication(authentication) ?? rootApiKey; +} + /** * Keep PAT/OAT authentication on the legacy path while routing machine API * keys through the RBAC controller, where plugin grants are applied. */ -type AuthenticationDependencies = { +type AuthenticationDependencies = ScopedApiKeyAuthenticationDependencies; + +type BootstrapAuthenticationDependencies = { authenticateRequest: typeof authenticateRequest; - authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope; + authenticateApiKeyRequest: typeof authenticateApiKeyRequest; }; export async function authenticateEnvironmentScopedApiRequest( request: Request, action: "read" | "write", resource: EnvironmentScopedResource, - dependencies: AuthenticationDependencies = { authenticateRequest, authenticateApiKeyWithScope } + dependencies?: AuthenticationDependencies +): Promise { + return authenticateRequestWithScopedApiKey( + request, + { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: { action, resource: { type: resource } }, + }, + dependencies + ); +} + +/** + * Bootstrap accepts any valid private environment key. Unlike env-var routes, + * it intentionally does not require a resource scope because it only echoes + * the credential the caller already presented. + */ +export async function authenticateEnvironmentBootstrapRequest( + request: Request, + dependencies: BootstrapAuthenticationDependencies = { + authenticateRequest, + authenticateApiKeyRequest, + } ): Promise { const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, { personalAccessToken: true, @@ -52,9 +86,8 @@ export async function authenticateEnvironmentScopedApiRequest( return { ok: true, authentication: userOrOrganizationAuthentication }; } - const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, { - action, - resource: { type: resource }, + const apiKeyAuthentication = await dependencies.authenticateApiKeyRequest(request, { + allowPreviewParent: true, }); if (!apiKeyAuthentication.ok) { return apiKeyAuthentication; diff --git a/apps/webapp/test/apiAuthScope.test.ts b/apps/webapp/test/apiAuthScope.test.ts index 7dadd9e2997..cfbd0435645 100644 --- a/apps/webapp/test/apiAuthScope.test.ts +++ b/apps/webapp/test/apiAuthScope.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it, vi } from "vitest"; -import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + authenticateApiKeyRequest, + authenticateApiKeyWithScope, + authenticateRequestWithScopedApiKey, +} from "~/services/apiAuth.server"; const authorizeBearer = vi.fn(); +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("authenticateApiKeyWithScope", () => { it("returns 401 without a bearer credential", async () => { const result = await authenticateApiKeyWithScope( @@ -59,7 +67,7 @@ describe("authenticateApiKeyWithScope", () => { expect(authorizeBearer).toHaveBeenCalledWith( request, { action: "read", resource: { type: "envvars" } }, - { allowJWT: true } + { allowJWT: true, allowPreviewParent: false } ); expect(result).toEqual({ ok: true, @@ -73,6 +81,59 @@ describe("authenticateApiKeyWithScope", () => { }); }); + it("allows branch creation to authenticate against the Preview parent", async () => { + const environment = { id: "env_preview" }; + const ability = { can: vi.fn(() => true), canSuper: vi.fn(() => true) }; + authorizeBearer.mockResolvedValueOnce({ + ok: true, + environment, + ability, + subject: { type: "apiKey", apiKeyId: "key_123" }, + }); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer tr_preview_sk_test" }, + }); + + await expect( + authenticateApiKeyWithScope( + request, + { + action: "write", + resource: { type: "branches" }, + allowPreviewParent: true, + }, + authorizeBearer + ) + ).resolves.toMatchObject({ ok: true }); + expect(authorizeBearer).toHaveBeenCalledWith( + request, + { action: "write", resource: { type: "branches" } }, + { allowJWT: false, allowPreviewParent: true } + ); + }); + + it("authenticates a valid API key without a resource check", async () => { + const environment = { id: "env_123" }; + const ability = { can: vi.fn(() => false), canSuper: vi.fn(() => false) }; + const authenticateBearer = vi.fn().mockResolvedValueOnce({ + ok: true, + environment, + ability, + subject: { type: "apiKey", apiKeyId: "key_123" }, + }); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer tr_prod_sk_test" }, + }); + + await expect( + authenticateApiKeyRequest(request, { allowPreviewParent: true }, authenticateBearer) + ).resolves.toMatchObject({ + ok: true, + authentication: { apiKey: "tr_prod_sk_test", environment }, + }); + expect(ability.can).not.toHaveBeenCalled(); + }); + it("returns authorization failures from the controller", async () => { const ability = { can: vi.fn(() => false), canSuper: vi.fn(() => false) }; authorizeBearer.mockResolvedValueOnce({ @@ -94,3 +155,61 @@ describe("authenticateApiKeyWithScope", () => { expect(ability.can).not.toHaveBeenCalled(); }); }); + +describe("authenticateRequestWithScopedApiKey", () => { + const options = { + personalAccessToken: true as const, + organizationAccessToken: true as const, + apiKey: { + action: "write", + resource: { type: "branches" }, + allowPreviewParent: true, + }, + }; + + it("keeps user and organization tokens on the legacy path", async () => { + const authentication = { + type: "personalAccessToken", + result: { userId: "user_123" }, + } as const; + const authenticateRequest = vi.fn().mockResolvedValueOnce(authentication); + const authenticateApiKeyWithScope = vi.fn(); + + await expect( + authenticateRequestWithScopedApiKey(new Request("https://example.com"), options, { + authenticateRequest, + authenticateApiKeyWithScope, + }) + ).resolves.toEqual({ ok: true, authentication }); + expect(authenticateRequest).toHaveBeenCalledWith(expect.any(Request), { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); + expect(authenticateApiKeyWithScope).not.toHaveBeenCalled(); + }); + + it("uses scoped RBAC authentication for API keys", async () => { + const apiKeyAuthentication = { + ok: true, + apiKey: "tr_preview_sk_test", + type: "PRIVATE", + environment: {}, + } as const; + const authenticateRequest = vi.fn().mockResolvedValueOnce(undefined); + const authenticateApiKeyWithScope = vi + .fn() + .mockResolvedValueOnce({ ok: true, authentication: apiKeyAuthentication }); + + await expect( + authenticateRequestWithScopedApiKey(new Request("https://example.com"), options, { + authenticateRequest, + authenticateApiKeyWithScope, + }) + ).resolves.toEqual({ + ok: true, + authentication: { type: "apiKey", result: apiKeyAuthentication }, + }); + expect(authenticateApiKeyWithScope).toHaveBeenCalledWith(expect.any(Request), options.apiKey); + }); +}); diff --git a/apps/webapp/test/environmentVariableApiAccess.test.ts b/apps/webapp/test/environmentVariableApiAccess.test.ts index e148bdb49a6..9ea63b040cc 100644 --- a/apps/webapp/test/environmentVariableApiAccess.test.ts +++ b/apps/webapp/test/environmentVariableApiAccess.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it, vi } from "vitest"; import { + apiKeyForProjectEnvironmentBootstrap, + authenticateEnvironmentBootstrapRequest, authenticateEnvVarApiRequest, presentedApiKeyFromAuthentication, } from "~/services/environmentVariableApiAccess.server"; const authenticateRequest = vi.fn(); +const authenticateApiKeyRequest = vi.fn(); const authenticateApiKeyWithScope = vi.fn(); const dependencies = { authenticateRequest, authenticateApiKeyWithScope }; @@ -31,6 +34,61 @@ describe("presentedApiKeyFromAuthentication", () => { }) ).toBeUndefined(); }); + + it("echoes only the presented API key during bootstrap", () => { + expect( + apiKeyForProjectEnvironmentBootstrap( + { + type: "apiKey", + result: { + ok: true, + apiKey: "tr_prod_sk_presented", + type: "PRIVATE", + environment: {}, + }, + }, + "tr_prod_root" + ) + ).toBe("tr_prod_sk_presented"); + }); + + it("returns the root key to an authorized user token", () => { + expect( + apiKeyForProjectEnvironmentBootstrap( + { + type: "personalAccessToken", + result: { userId: "user_123" }, + }, + "tr_prod_root" + ) + ).toBe("tr_prod_root"); + }); +}); + +describe("authenticateEnvironmentBootstrapRequest", () => { + it("authenticates API keys without requiring an API-key scope", async () => { + authenticateRequest.mockResolvedValueOnce(undefined); + const authentication = { + ok: true, + apiKey: "tr_preview_sk_presented", + type: "PRIVATE", + environment: {}, + }; + authenticateApiKeyRequest.mockResolvedValueOnce({ ok: true, authentication }); + + await expect( + authenticateEnvironmentBootstrapRequest(new Request("https://example.com"), { + authenticateRequest, + authenticateApiKeyRequest, + }) + ).resolves.toEqual({ + ok: true, + authentication: { type: "apiKey", result: authentication }, + }); + expect(authenticateApiKeyRequest).toHaveBeenCalledWith(expect.any(Request), { + allowPreviewParent: true, + }); + }); }); describe("authenticateEnvVarApiRequest", () => { diff --git a/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts b/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts index 4ac3cde951e..9a24883a85a 100644 --- a/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts +++ b/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts @@ -1,18 +1,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - authenticateEnvironmentScopedApiRequest: vi.fn<(...args: any[]) => Promise>(), + authenticateEnvironmentBootstrapRequest: vi.fn<(...args: any[]) => Promise>(), authorizePatEnvironmentAccess: vi.fn<(...args: any[]) => Promise>(), authenticatedEnvironmentForAuthentication: vi.fn<(...args: any[]) => Promise>(), })); vi.mock("~/services/environmentVariableApiAccess.server", () => ({ - authenticateEnvironmentScopedApiRequest: mocks.authenticateEnvironmentScopedApiRequest, + authenticateEnvironmentBootstrapRequest: mocks.authenticateEnvironmentBootstrapRequest, authorizePatEnvironmentAccess: mocks.authorizePatEnvironmentAccess, - presentedApiKeyFromAuthentication: (authentication: any) => + apiKeyForProjectEnvironmentBootstrap: (authentication: any, rootApiKey: string) => authentication.type === "apiKey" && authentication.result.ok ? authentication.result.apiKey - : undefined, + : rootApiKey, })); vi.mock("~/services/apiAuth.server", () => ({ authenticatedEnvironmentForAuthentication: mocks.authenticatedEnvironmentForAuthentication, @@ -54,7 +54,7 @@ async function responseJson(response: Response) { describe("project environment credential response", () => { beforeEach(() => { - mocks.authenticateEnvironmentScopedApiRequest.mockReset(); + mocks.authenticateEnvironmentBootstrapRequest.mockReset(); mocks.authorizePatEnvironmentAccess.mockReset(); mocks.authenticatedEnvironmentForAuthentication.mockReset(); @@ -63,7 +63,7 @@ describe("project environment credential response", () => { }); it("returns the presented API key", async () => { - mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({ + mocks.authenticateEnvironmentBootstrapRequest.mockResolvedValue({ ok: true, authentication: { type: "apiKey", @@ -83,10 +83,11 @@ describe("project environment credential response", () => { apiKey: "tr_prod_sk_presented", projectId: "proj_123", }); + expect(mocks.authorizePatEnvironmentAccess).not.toHaveBeenCalled(); }); it("does not exchange a grace-window root key for the current root key", async () => { - mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({ + mocks.authenticateEnvironmentBootstrapRequest.mockResolvedValue({ ok: true, authentication: { type: "apiKey", @@ -109,7 +110,7 @@ describe("project environment credential response", () => { }); it("returns the root key to an authorized user token", async () => { - mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({ + mocks.authenticateEnvironmentBootstrapRequest.mockResolvedValue({ ok: true, authentication: { type: "personalAccessToken", @@ -123,5 +124,6 @@ describe("project environment credential response", () => { await expect(responseJson(response)).resolves.toMatchObject({ apiKey: "tr_prod_root_secret", }); + expect(mocks.authorizePatEnvironmentAccess).toHaveBeenCalledOnce(); }); }); diff --git a/apps/webapp/test/rbacFallbackBranch.test.ts b/apps/webapp/test/rbacFallbackBranch.test.ts index 2887c94ad9e..95c3b161da8 100644 --- a/apps/webapp/test/rbacFallbackBranch.test.ts +++ b/apps/webapp/test/rbacFallbackBranch.test.ts @@ -195,6 +195,49 @@ describe("RBAC fallback — additional keys", () => { expect(apiKeyFind).not.toHaveBeenCalled(); }); + postgresTest("rejects revoked and expired additional keys", async ({ prisma }) => { + const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const revoked = generateAdditionalApiKey("PRODUCTION").apiKey; + const expired = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.createMany({ + data: [ + { + name: "Revoked deploy key", + keyHash: createHash("sha256").update(revoked).digest("hex"), + lastFour: revoked.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + scopes: ["admin"], + revokedAt: new Date(), + }, + { + name: "Expired deploy key", + keyHash: createHash("sha256").update(expired).digest("hex"), + lastFour: expired.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + scopes: ["admin"], + expiresAt: new Date(Date.now() - 1_000), + }, + ], + }); + + await expect(rbac.authenticateBearer(bearerRequest(revoked))).resolves.toMatchObject({ + ok: false, + status: 401, + }); + await expect(rbac.authenticateBearer(bearerRequest(expired))).resolves.toMatchObject({ + ok: false, + status: 401, + }); + }); + postgresTest("authenticates an additional key and records its use", async ({ prisma }) => { const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); const rbac = makeController(prisma); @@ -551,6 +594,41 @@ describe("RBAC fallback — branch header guards", () => { }); }); + postgresTest( + "allows branch management to authenticate against the preview parent", + async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const previewParent = await createEnv(prisma, project.id, organization.id, { + type: "PREVIEW", + isBranchableEnvironment: true, + }); + const additional = generateAdditionalApiKey("PREVIEW").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Deploy key", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: previewParent.id, + createdByUserId: user.id, + presetId: "DEPLOY_ONLY", + scopes: ["write:branches"], + }, + }); + + const result = await rbac.authenticateBearer(bearerRequest(additional), { + allowPreviewParent: true, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.environment.id).toBe(previewParent.id); + expect(result.ability.can("write", { type: "branches" })).toBe(true); + expect(result.ability.can("write", { type: "deployments" })).toBe(false); + } + ); + // The "default" sentinel is DEVELOPMENT-only: it maps the dev root env to its // (branchless) self. For PREVIEW, "default" is an ordinary branch name, so a // PREVIEW branch literally named "default" is reachable and the request pivots diff --git a/internal-packages/rbac/src/bearerCredentials.ts b/internal-packages/rbac/src/bearerCredentials.ts index 6b209c9f37e..d1e4685c8ec 100644 --- a/internal-packages/rbac/src/bearerCredentials.ts +++ b/internal-packages/rbac/src/bearerCredentials.ts @@ -5,6 +5,7 @@ import type { Prisma, PrismaClient } from "@trigger.dev/database"; import { scopesGrantFullAccess, type AuthenticatedEnvironment, + type BearerAuthOptions, type BearerAuthResult, } from "@trigger.dev/plugins"; import { createHash } from "node:crypto"; @@ -80,7 +81,7 @@ export class BearerCredentialResolver { async authenticate( request: Request, - options?: { allowJWT?: boolean } + options?: BearerAuthOptions ): Promise { // Deprecated public API keys (`pk_*` minted long before public JWTs // landed) are intentionally NOT handled here. That token format hasn't @@ -221,15 +222,16 @@ export class BearerCredentialResolver { }; } - return this.resolveAdditionalKey(rawToken, branchName); + return this.resolveAdditionalKey(rawToken, branchName, options?.allowPreviewParent); } - return this.resolveRootKey(rawToken, branchName); + return this.resolveRootKey(rawToken, branchName, options?.allowPreviewParent); } private async resolveRootKey( rawToken: string, - branchName: string | null + branchName: string | null, + allowPreviewParent = false ): Promise { const include = environmentInclude(branchName); const now = new Date(); @@ -270,7 +272,7 @@ export class BearerCredentialResolver { }; } - const [branchError, resolvedEnvironment] = resolveBranch(env, branchName); + const [branchError, resolvedEnvironment] = resolveBranch(env, branchName, allowPreviewParent); if (branchError !== null) { return { ok: false, @@ -296,7 +298,8 @@ export class BearerCredentialResolver { private async resolveAdditionalKey( rawToken: string, - branchName: string | null + branchName: string | null, + allowPreviewParent = false ): Promise { const resolution: BearerResolution = { credentialKind: "additional_api_key", @@ -321,7 +324,11 @@ export class BearerCredentialResolver { return { ok: false, status: 401, error: "Invalid API key", resolution }; } - const [branchError, resolvedEnvironment] = resolveBranch(match.runtimeEnvironment, branchName); + const [branchError, resolvedEnvironment] = resolveBranch( + match.runtimeEnvironment, + branchName, + allowPreviewParent + ); if (branchError !== null) { return { ok: false, @@ -387,9 +394,10 @@ type BranchResolution = function resolveBranch( environment: EnvironmentWithBranches, - branchName: string | null + branchName: string | null, + allowPreviewParent: boolean ): BranchResolution { - if (environment.type === "PREVIEW" && !branchName) { + if (environment.type === "PREVIEW" && !branchName && !allowPreviewParent) { return ["x-trigger-branch header required for preview env", null]; } diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index 9b814591e3d..2d9bb9cea91 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -4,6 +4,7 @@ import type { RbacUser, RbacSubject, RbacResource, + BearerAuthOptions, BearerAuthResult, PatAuthResult, SessionAuthResult, @@ -87,7 +88,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { async authenticateBearer( request: Request, - options?: { allowJWT?: boolean } + options?: BearerAuthOptions ): Promise { return this.bearer.authenticate(request, options); } @@ -174,7 +175,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { async authenticateAuthorizeBearer( request: Request, check: { action: string; resource: RbacResource | RbacResource[] }, - options?: { allowJWT?: boolean } + options?: BearerAuthOptions ): Promise { const auth = await this.authenticateBearer(request, options); if (!auth.ok) return auth; diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index d05b009c6e5..5ee4bcd761c 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -1,6 +1,7 @@ import type { ApiKeyPolicyDescription, ApiKeyPreset, + BearerAuthOptions, BearerAuthResult, Permission, PrepareApiKeyPolicyResult, @@ -22,7 +23,12 @@ import { RoleBaseAccessFallback } from "./fallback.js"; // exchange route imports it to project requested scopes against it. export { CAPLESS_USER_ACTOR_SCOPES } from "./fallback.js"; import { BearerCredentialResolver, type BearerResolution } from "./bearerCredentials.js"; -export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins"; +export type { + BearerAuthOptions, + RoleBaseAccessController, + RbacAbility, + RbacResource, +} from "@trigger.dev/plugins"; export type { BearerCredentialKind, BearerLookupPath, @@ -42,10 +48,7 @@ export type { export type HostBearerAuthResult = BearerAuthResult & { resolution: BearerResolution }; export type HostRbacController = Omit, "authenticateBearer"> & { - authenticateBearer( - request: Request, - options?: { allowJWT?: boolean } - ): Promise; + authenticateBearer(request: Request, options?: BearerAuthOptions): Promise; }; export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins"; export { buildJwtAbility, scopesWithinAbility } from "./ability.js"; diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 0c6eece0a54..f82942d4e43 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -30,6 +30,7 @@ import { wrapCommandAction, } from "../cli/common.js"; import { loadConfig } from "../config.js"; +import { authenticateForDeploy, userIdForDeploy } from "../deploy/auth.js"; import { buildImage } from "../deploy/buildImage.js"; import { checkLogsForErrors, @@ -268,11 +269,12 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { verifyDirectory(dir, projectPath); - const authorization = await login({ - embedded: true, - defaultApiUrl: options.apiUrl, + const authorization = await authenticateForDeploy({ + accessToken: process.env.TRIGGER_ACCESS_TOKEN, + apiUrl: process.env.TRIGGER_API_URL ?? options.apiUrl, profile: options.profile, silent: options.plain, + login, }); if (!authorization.ok) { @@ -368,7 +370,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { config: resolvedConfig, dashboardUrl: authorization.dashboardUrl, options, - userId: authorization.auth.tokenType === "personal" ? authorization.userId : undefined, + userId: userIdForDeploy(authorization), gitMeta, }); return; @@ -424,7 +426,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { projectClient.client, { contentHash: buildManifest.contentHash, - userId: authorization.auth.tokenType === "personal" ? authorization.userId : undefined, + userId: userIdForDeploy(authorization), gitMeta, type: features.run_engine_v2 ? "MANAGED" : "V1", runtime: buildManifest.runtime, diff --git a/packages/cli-v3/src/commands/preview.ts b/packages/cli-v3/src/commands/preview.ts index 314c586e1f1..ed74656f044 100644 --- a/packages/cli-v3/src/commands/preview.ts +++ b/packages/cli-v3/src/commands/preview.ts @@ -14,7 +14,6 @@ import { loadConfig } from "../config.js"; import { createGitMeta } from "../utilities/gitMeta.js"; import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; import { logger } from "../utilities/logger.js"; -import type { LoginResultOk } from "../utilities/session.js"; import { spinner } from "../utilities/windows.js"; import { verifyDirectory } from "./deploy.js"; import { login } from "./login.js"; @@ -129,7 +128,7 @@ async function _previewArchiveCommand(dir: string, options: PreviewCommandOption } export async function archivePreviewBranch( - authorization: LoginResultOk, + authorization: { auth: { apiUrl: string; accessToken: string } }, branch: string, project: string ) { diff --git a/packages/cli-v3/src/deploy/auth.test.ts b/packages/cli-v3/src/deploy/auth.test.ts new file mode 100644 index 00000000000..89b79637dbc --- /dev/null +++ b/packages/cli-v3/src/deploy/auth.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../utilities/configFiles.js", () => ({ + readAuthConfigProfile: vi.fn(() => undefined), +})); + +import { authenticateForDeploy, userIdForDeploy } from "./auth.js"; +import { readAuthConfigProfile } from "../utilities/configFiles.js"; + +describe("authenticateForDeploy", () => { + it("uses an API key from TRIGGER_ACCESS_TOKEN without logging in", async () => { + let loginCalled = false; + + const result = await authenticateForDeploy({ + accessToken: "tr_prod_sk_deploy", + apiUrl: "https://example.trigger.dev", + profile: "default", + silent: true, + login: async () => { + loginCalled = true; + return { ok: false, error: "should not be called" }; + }, + }); + + expect(loginCalled).toBe(false); + expect(result).toEqual({ + ok: true, + profile: "default", + dashboardUrl: "https://example.trigger.dev", + auth: { + apiUrl: "https://example.trigger.dev", + accessToken: "tr_prod_sk_deploy", + tokenType: "apiKey", + }, + }); + }); + + it("derives hosted dashboard links without user metadata", async () => { + const result = await authenticateForDeploy({ + accessToken: "tr_preview_sk_deploy", + apiUrl: "https://api.example.trigger.dev", + profile: "default", + silent: true, + login: async () => ({ ok: false, error: "should not be called" }), + }); + + expect(result).toMatchObject({ + dashboardUrl: "https://example.trigger.dev", + auth: { apiUrl: "https://api.example.trigger.dev" }, + }); + }); + + it("uses the normal cloud URLs when no API URL is set", async () => { + const result = await authenticateForDeploy({ + accessToken: "tr_prod_sk_deploy", + profile: "default", + silent: true, + login: async () => ({ ok: false, error: "should not be called" }), + }); + + expect(result).toMatchObject({ + dashboardUrl: "https://cloud.trigger.dev", + auth: { apiUrl: "https://api.trigger.dev" }, + }); + }); + + it("falls back to the saved profile's API URL for self-hosted instances", async () => { + vi.mocked(readAuthConfigProfile).mockReturnValueOnce({ + apiUrl: "https://trigger.internal.example.com", + }); + + const result = await authenticateForDeploy({ + accessToken: "tr_prod_sk_deploy", + profile: "selfhosted", + silent: true, + login: async () => ({ ok: false, error: "should not be called" }), + }); + + expect(result).toMatchObject({ + dashboardUrl: "https://trigger.internal.example.com", + auth: { apiUrl: "https://trigger.internal.example.com" }, + }); + }); + + it("prefers an explicit API URL over the saved profile", async () => { + vi.mocked(readAuthConfigProfile).mockReturnValueOnce({ + apiUrl: "https://trigger.internal.example.com", + }); + + const result = await authenticateForDeploy({ + accessToken: "tr_prod_sk_deploy", + apiUrl: "https://api.trigger.dev", + profile: "selfhosted", + silent: true, + login: async () => ({ ok: false, error: "should not be called" }), + }); + + expect(result).toMatchObject({ + auth: { apiUrl: "https://api.trigger.dev" }, + }); + }); + + it("passes a PAT through to the login path", async () => { + let loginOptions: unknown; + const result = await authenticateForDeploy({ + accessToken: "tr_pat_abc123", + apiUrl: "https://example.trigger.dev", + profile: "ci", + silent: false, + login: async (options) => { + loginOptions = options; + return { ok: false, error: "login result" }; + }, + }); + + expect(loginOptions).toEqual({ + embedded: true, + defaultApiUrl: "https://example.trigger.dev", + profile: "ci", + silent: false, + }); + expect(result).toEqual({ ok: false, error: "login result" }); + }); + + it("passes an OAT through to the login path", async () => { + let loginOptions: unknown; + const result = await authenticateForDeploy({ + accessToken: "tr_oat_abc123", + apiUrl: "https://example.trigger.dev", + profile: "ci", + silent: false, + login: async (options) => { + loginOptions = options; + return { ok: false, error: "login result" }; + }, + }); + + expect(loginOptions).toEqual({ + embedded: true, + defaultApiUrl: "https://example.trigger.dev", + profile: "ci", + silent: false, + }); + expect(result).toEqual({ ok: false, error: "login result" }); + }); + + it("keeps login authentication when no access token is set", async () => { + let loginOptions: unknown; + const result = await authenticateForDeploy({ + apiUrl: "https://example.trigger.dev", + profile: "ci", + silent: false, + login: async (options) => { + loginOptions = options; + return { ok: false, error: "login result" }; + }, + }); + + expect(loginOptions).toEqual({ + embedded: true, + defaultApiUrl: "https://example.trigger.dev", + profile: "ci", + silent: false, + }); + expect(result).toEqual({ ok: false, error: "login result" }); + }); + + it("throws a descriptive error for an invalid API URL", async () => { + await expect( + authenticateForDeploy({ + accessToken: "tr_prod_sk_deploy", + apiUrl: "not-a-url", + profile: "default", + silent: true, + login: async () => ({ ok: false, error: "should not be called" }), + }) + ).rejects.toThrow( + 'Invalid API URL "not-a-url". Check your TRIGGER_API_URL environment variable or --api-url flag.' + ); + }); +}); + +describe("userIdForDeploy", () => { + it("omits user attribution for API-key deployments", () => { + expect( + userIdForDeploy({ + ok: true, + profile: "default", + dashboardUrl: "https://cloud.trigger.dev", + auth: { + apiUrl: "https://api.trigger.dev", + accessToken: "tr_prod_sk_deploy", + tokenType: "apiKey", + }, + }) + ).toBeUndefined(); + }); +}); diff --git a/packages/cli-v3/src/deploy/auth.ts b/packages/cli-v3/src/deploy/auth.ts new file mode 100644 index 00000000000..72f562e38ff --- /dev/null +++ b/packages/cli-v3/src/deploy/auth.ts @@ -0,0 +1,96 @@ +import { CLOUD_API_URL, CLOUD_WEB_URL } from "../consts.js"; +import { readAuthConfigProfile } from "../utilities/configFiles.js"; +import type { LoginResult, LoginResultOk } from "../utilities/session.js"; + +const personalTokenPrefix = "tr_pat_"; +const organizationTokenPrefix = "tr_oat_"; +const apiKeyPrefix = "tr_"; + +export type DeployAuthorization = + | LoginResultOk + | { + ok: true; + profile: string; + dashboardUrl: string; + auth: { + apiUrl: string; + accessToken: string; + tokenType: "apiKey"; + }; + }; + +type LoginForDeploy = (options: { + embedded: true; + defaultApiUrl: string; + profile: string; + silent: boolean; +}) => Promise; + +export async function authenticateForDeploy({ + accessToken, + apiUrl, + profile, + silent, + login, +}: { + accessToken?: string; + apiUrl?: string; + profile: string; + silent: boolean; + login: LoginForDeploy; +}): Promise { + const authConfig = readAuthConfigProfile(profile); + const resolvedApiUrl = apiUrl ?? authConfig?.apiUrl ?? CLOUD_API_URL; + + const isApiKey = + !!accessToken && + accessToken.startsWith(apiKeyPrefix) && + !accessToken.startsWith(personalTokenPrefix) && + !accessToken.startsWith(organizationTokenPrefix); + + if (!isApiKey) { + return login({ + embedded: true, + defaultApiUrl: resolvedApiUrl, + profile, + silent, + }); + } + + return { + ok: true, + profile, + dashboardUrl: dashboardUrlForApiUrl(resolvedApiUrl), + auth: { + apiUrl: resolvedApiUrl, + accessToken, + tokenType: "apiKey", + }, + }; +} + +function dashboardUrlForApiUrl(apiUrl: string): string { + if (apiUrl === CLOUD_API_URL) { + return CLOUD_WEB_URL; + } + + try { + const url = new URL(apiUrl); + if (url.hostname.startsWith("api.") && url.hostname.endsWith(".trigger.dev")) { + url.hostname = url.hostname.slice(4); + return url.toString().replace(/\/$/, ""); + } + } catch { + throw new Error( + `Invalid API URL "${apiUrl}". Check your TRIGGER_API_URL environment variable or --api-url flag.` + ); + } + + return apiUrl; +} + +export function userIdForDeploy(authorization: DeployAuthorization): string | undefined { + return authorization.auth.tokenType === "personal" && "userId" in authorization + ? authorization.userId + : undefined; +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 198efce3fc3..efaabedf51f 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -25,6 +25,7 @@ export type { AuthenticatedEnvironment, RbacScopeAction, RbacScopeResourceType, + BearerAuthOptions, } from "./rbac.js"; export { diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts index 3a01a480d7f..e32187e215b 100644 --- a/packages/plugins/src/rbac.ts +++ b/packages/plugins/src/rbac.ts @@ -198,6 +198,7 @@ export type RbacScopeResourceType = | "deployments" | "envvars" | "apiKeys" + | "branches" | "sessions" | "waitpoints" | "tags" @@ -381,6 +382,13 @@ export type BearerAuthResult = jwt?: { realtime?: { skipColumns?: string[] }; oneTimeUse?: boolean; act?: { sub: string } }; }; +export type BearerAuthOptions = { + allowJWT?: boolean; + // Branch creation authenticates against the branchable Preview parent before + // the requested child environment exists. + allowPreviewParent?: boolean; +}; + export type SessionAuthResult = | { ok: false; reason: "unauthenticated" | "unauthorized" } | { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility }; @@ -433,7 +441,7 @@ export interface RoleBaseAccessController { // API routes (Bearer token): one DB query → identity + pre-built ability // options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys - authenticateBearer(request: Request, options?: { allowJWT?: boolean }): Promise; + authenticateBearer(request: Request, options?: BearerAuthOptions): Promise; // Dashboard loaders/actions (session cookie): one DB query → user + pre-built ability. // The caller resolves `userId` from the session cookie and passes it in. @@ -481,7 +489,7 @@ export interface RoleBaseAccessController { authenticateAuthorizeBearer( request: Request, check: { action: string; resource: RbacResource | RbacResource[] }, - options?: { allowJWT?: boolean } + options?: BearerAuthOptions ): Promise; authenticateAuthorizeSession(