diff --git a/.changeset/friendly-keys-batch-tokens.md b/.changeset/friendly-keys-batch-tokens.md index 505edacd33..db72512199 100644 --- a/.changeset/friendly-keys-batch-tokens.md +++ b/.changeset/friendly-keys-batch-tokens.md @@ -3,4 +3,4 @@ "@trigger.dev/sdk": patch --- -Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results. +Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Task-scoped keys can use batch operations for their permitted tasks, while server-issued public access tokens let environment-scoped API keys read batch results. diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 939cd55e94..84d2979bb1 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -5,7 +5,11 @@ import { runStore } from "~/v3/runStore.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; import { logger } from "~/services/logger.server"; import { getUsername } from "~/utils/username"; +import { hashApiKey } from "~/utils/apiKeys"; +import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { scopesGrantFullAccess } from "@trigger.dev/rbac"; +import { authFeatureControls } from "~/services/authFeatureControls.server"; export type { RuntimeEnvironment }; @@ -93,11 +97,22 @@ export function toAuthenticated( }; } -export async function findEnvironmentByApiKey( +export type ApiKeyEnvironmentResolution = + | { ok: true; environment: AuthenticatedEnvironment } + | { ok: false; reason: "not-found" | "restricted" | "disabled" }; + +/** + * Resolve an environment from a raw API key for legacy routes that do not + * declare authorization. Additional keys are accepted only when their stored + * scopes explicitly grant full access; restricted keys fail closed here + * (`reason: "restricted"`, so callers can explain the rejection). + */ +async function resolveEnvironmentByApiKey( apiKey: string, branchName: string | undefined, - tx: PrismaClientOrTransaction = $replica -): Promise { + tx: PrismaClientOrTransaction, + additionalApiKeyLookupEnabled: () => boolean +): Promise { const branch = sanitizeBranchName(branchName) ?? undefined; const include = { @@ -112,35 +127,94 @@ export async function findEnvironmentByApiKey( : undefined, } satisfies Prisma.RuntimeEnvironmentInclude; - let environment = await tx.runtimeEnvironment.findFirst({ - where: { - apiKey, - }, - include, - }); + const now = new Date(); + const routesToAdditionalKey = isAdditionalApiKey(apiKey); + if (routesToAdditionalKey && !additionalApiKeyLookupEnabled()) { + return { ok: false, reason: "disabled" }; + } - // Fall back to keys that were revoked within the grace window - if (!environment) { + let rootEnvironment = routesToAdditionalKey + ? null + : await tx.runtimeEnvironment.findFirst({ + where: { + apiKey, + }, + include, + }); + + // Fall back to root keys that were rotated within the grace window. + if (!routesToAdditionalKey && !rootEnvironment) { const revokedApiKey = await tx.revokedApiKey.findFirst({ where: { apiKey, - expiresAt: { gt: new Date() }, + expiresAt: { gt: now }, }, include: { runtimeEnvironment: { include }, }, }); - environment = revokedApiKey?.runtimeEnvironment ?? null; + rootEnvironment = revokedApiKey?.runtimeEnvironment ?? null; } + // Additional keys are host-owned credentials. Legacy routes cannot apply a + // scoped ability, so only an explicit full-access scope is accepted. + const match = routesToAdditionalKey + ? await tx.apiKey.findFirst({ + where: { + keyHash: hashApiKey(apiKey), + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + select: { + id: true, + lastUsedAt: true, + scopes: true, + runtimeEnvironment: { include }, + }, + }) + : null; + + if (match && !scopesGrantFullAccess(match.scopes)) { + return { ok: false, reason: "restricted" }; + } + + const additionalApiKey = match ? { id: match.id, lastUsedAt: match.lastUsedAt } : null; + let environment = rootEnvironment ?? match?.runtimeEnvironment ?? null; + if (!environment) { - return null; + return { ok: false, reason: "not-found" }; + } + + if ( + additionalApiKey && + (!additionalApiKey.lastUsedAt || + additionalApiKey.lastUsedAt < new Date(now.getTime() - 300_000)) + ) { + try { + // Deliberately the primary `prisma`, not `tx`: `tx` defaults to the + // read replica (and may be a caller's transaction), and this last-used + // telemetry write must hit the writer. It's throttled to once every 5 + // minutes per key and best-effort — auth never fails if it can't record. + await prisma.apiKey.updateMany({ + where: { + id: additionalApiKey.id, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + data: { lastUsedAt: now }, + }); + } catch (error) { + logger.warn("Failed to update API key last-used timestamp", { + apiKeyId: additionalApiKey.id, + error, + }); + } } //don't return deleted projects if (environment.project.deletedAt !== null) { - return null; + return { ok: false, reason: "not-found" }; } if (environment.type === "PREVIEW") { @@ -148,23 +222,26 @@ export async function findEnvironmentByApiKey( logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", { environmentId: environment.id, }); - return null; + return { ok: false, reason: "not-found" }; } const childEnvironment = environment.childEnvironments.at(0); if (childEnvironment) { - return toAuthenticated({ - ...childEnvironment, - apiKey: environment.apiKey, - orgMember: environment.orgMember, - organization: environment.organization, - project: environment.project, - }); + return { + ok: true, + environment: toAuthenticated({ + ...childEnvironment, + apiKey: environment.apiKey, + orgMember: environment.orgMember, + organization: environment.organization, + project: environment.project, + }), + }; } //A branch was specified but no child environment was found - return null; + return { ok: false, reason: "not-found" }; } // If there is a named DEV branch (other than default), return it @@ -172,20 +249,56 @@ export async function findEnvironmentByApiKey( const childEnvironment = environment.childEnvironments.at(0); if (childEnvironment) { - return toAuthenticated({ - ...childEnvironment, - apiKey: environment.apiKey, - orgMember: environment.orgMember, - organization: environment.organization, - project: environment.project, - }); + return { + ok: true, + environment: toAuthenticated({ + ...childEnvironment, + apiKey: environment.apiKey, + orgMember: environment.orgMember, + organization: environment.organization, + project: environment.project, + }), + }; } //A branch was specified but no child environment was found - return null; + return { ok: false, reason: "not-found" }; } - return toAuthenticated(environment); + return { ok: true, environment: toAuthenticated(environment) }; +} + +/** + * Resolve an environment from a raw API key. Root and grace-window keys keep + * their legacy behavior; additional keys with restricted scopes fail closed. + */ +export async function findEnvironmentByApiKey( + apiKey: string, + branchName: string | undefined, + tx: PrismaClientOrTransaction = $replica, + additionalApiKeyLookupEnabled = authFeatureControls.additionalApiKeyLookupEnabled +): Promise { + const resolution = await resolveEnvironmentByApiKey( + apiKey, + branchName, + tx, + additionalApiKeyLookupEnabled + ); + return resolution.ok ? resolution.environment : null; +} + +/** + * Like `findEnvironmentByApiKey`, but distinguishes a restricted additional + * key (fails closed on legacy routes) from an unknown key so callers can + * return an accurate error message. + */ +export async function findEnvironmentByApiKeyWithResolution( + apiKey: string, + branchName: string | undefined, + tx: PrismaClientOrTransaction = $replica, + additionalApiKeyLookupEnabled = authFeatureControls.additionalApiKeyLookupEnabled +): Promise { + return resolveEnvironmentByApiKey(apiKey, branchName, tx, additionalApiKeyLookupEnabled); } /** diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index c6a14a4492..392c639b53 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -14,6 +14,7 @@ import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization"; import { BatchId } from "@trigger.dev/core/v3/isomorphic"; import { getUserProvidedIdempotencyKey } from "@trigger.dev/core/v3/serverOnly"; import type { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database"; +import type { RbacAbility } from "@trigger.dev/rbac"; import assertNever from "assert-never"; import type { API_VERSIONS, RunStatusUnspecifiedApiVersion } from "~/api/versions"; import { CURRENT_API_VERSION } from "~/api/versions"; @@ -83,6 +84,24 @@ type CommonRelatedRunWithVersion = CommonRelatedRun & { // ReturnType) so findRun can return a synthesised buffered // run without the type becoming self-referential. Exported so the // buffer-synthesis helper below can match this shape under unit test. +function canReadRelatedRun( + ability: RbacAbility | undefined, + run: CommonRelatedRunWithVersion +): boolean { + if (!ability) return true; + + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + + return ability.can("read", resources); +} + export type FoundRun = CommonRelatedRunWithVersion & { traceId: string; payload: string; @@ -212,7 +231,7 @@ export class ApiRetrieveRunPresenter { return synthesiseFoundRunFromBuffer(buffered); } - public async call(taskRun: FoundRun, env: AuthenticatedEnvironment) { + public async call(taskRun: FoundRun, env: AuthenticatedEnvironment, ability?: RbacAbility) { return startSpanWithEnv(tracer, "ApiRetrieveRunPresenter.call", env, async () => { let $payload: any; let $payloadPresignedUrl: string | undefined; @@ -290,14 +309,18 @@ export class ApiRetrieveRunPresenter { taskRun.engine === "V1" ? taskRun.attempts.length : (taskRun.attemptNumber ?? 0), attempts: [], relatedRuns: { - root: taskRun.rootTaskRun - ? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion) - : undefined, - parent: taskRun.parentTaskRun - ? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion) - : undefined, + root: + taskRun.rootTaskRun && canReadRelatedRun(ability, taskRun.rootTaskRun) + ? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion) + : undefined, + parent: + taskRun.parentTaskRun && canReadRelatedRun(ability, taskRun.parentTaskRun) + ? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion) + : undefined, children: await Promise.all( - taskRun.childRuns.map(async (r) => await createCommonRunStructure(r, this.apiVersion)) + taskRun.childRuns + .filter((run) => canReadRelatedRun(ability, run)) + .map(async (run) => await createCommonRunStructure(run, this.apiVersion)) ), }, }; diff --git a/apps/webapp/app/routes/api.v1.artifacts.ts b/apps/webapp/app/routes/api.v1.artifacts.ts index c74c66a222..a706f9e04e 100644 --- a/apps/webapp/app/routes/api.v1.artifacts.ts +++ b/apps/webapp/app/routes/api.v1.artifacts.ts @@ -4,7 +4,7 @@ import { CreateArtifactRequestBody, tryCatch, } from "@trigger.dev/core/v3"; -import { authenticateRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ArtifactsService } from "~/v3/services/artifacts.server"; @@ -14,17 +14,19 @@ export async function action({ request }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - apiKey: true, - organizationAccessToken: false, - personalAccessToken: false, + // Artifact uploads are part of the deploy flow (deployment context archive). + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, }); - if (!authenticationResult || !authenticationResult.result.ok) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = { result: authResult.authentication }; + const [, rawBody] = await tryCatch(request.json()); const body = CreateArtifactRequestBody.safeParse(rawBody ?? {}); diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts index 9e7052b727..f0d419ca18 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { CreateDeclarativeScheduleError } from "~/v3/services/createBackgroundWorker.server"; @@ -26,13 +26,18 @@ export async function action({ request, params }: ActionFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts index d14f7a8bc2..168de47675 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts @@ -1,7 +1,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { CancelDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { DeploymentService } from "~/v3/services/deployment.server"; @@ -21,18 +21,17 @@ export async function action({ request, params }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - apiKey: true, - organizationAccessToken: false, - personalAccessToken: false, + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, }); - if (!authenticationResult || !authenticationResult.result.ok) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } - const { environment: authenticatedEnv } = authenticationResult.result; + const { environment: authenticatedEnv } = authResult.authentication; const { deploymentId } = parsedParams.data; const [, rawBody] = await tryCatch(request.json()); diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.fail.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.fail.ts index 7ba5dd3700..df596716a8 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.fail.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.fail.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { FailDeploymentRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { FailDeploymentService } from "~/v3/services/failDeployment.server"; @@ -24,13 +24,18 @@ export async function action({ request, params }: ActionFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts index f576f6eef3..7b9c541949 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { FinalizeDeploymentService } from "~/v3/services/finalizeDeployment.server"; @@ -25,13 +25,18 @@ export async function action({ request, params }: ActionFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts index 78488bfeeb..fe60b9ace4 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts @@ -5,7 +5,7 @@ import { tryCatch, } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { DeploymentService } from "~/v3/services/deployment.server"; @@ -25,18 +25,17 @@ export async function action({ request, params }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - apiKey: true, - organizationAccessToken: false, - personalAccessToken: false, + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, }); - if (!authenticationResult || !authenticationResult.result.ok) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } - const { environment: authenticatedEnv } = authenticationResult.result; + const { environment: authenticatedEnv } = authResult.authentication; const { deploymentId } = parsedParams.data; const [, rawBody] = await tryCatch(request.json()); diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts index 671f42606a..839e5a5cf5 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts @@ -1,7 +1,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { ProgressDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { DeploymentService } from "~/v3/services/deployment.server"; @@ -21,18 +21,17 @@ export async function action({ request, params }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - apiKey: true, - organizationAccessToken: false, - personalAccessToken: false, + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, }); - if (!authenticationResult || !authenticationResult.result.ok) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } - const { environment: authenticatedEnv } = authenticationResult.result; + const { environment: authenticatedEnv } = authResult.authentication; const { deploymentId } = parsedParams.data; const [, rawBody] = await tryCatch(request.json()); diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index dd16fabf24..6b7accd029 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -2,7 +2,7 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ @@ -18,13 +18,18 @@ export async function loader({ request, params }: LoaderFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts index f2c69078b9..c7cce0d1ed 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server"; @@ -25,13 +25,18 @@ export async function action({ request, params }: ActionFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const url = new URL(request.url); diff --git a/apps/webapp/app/routes/api.v1.deployments.latest.ts b/apps/webapp/app/routes/api.v1.deployments.latest.ts index 7609f87425..9354db4de3 100644 --- a/apps/webapp/app/routes/api.v1.deployments.latest.ts +++ b/apps/webapp/app/routes/api.v1.deployments.latest.ts @@ -2,19 +2,24 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { WorkerInstanceGroupType } from "@trigger.dev/database"; import { prisma } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; export async function loader({ request }: LoaderFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const deployment = await prisma.workerDeployment.findFirst({ diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 39988e1d13..273259d87d 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -5,7 +5,7 @@ import { type InitializeDeploymentResponseBody, } from "@trigger.dev/core/v3"; import { $replica } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -18,13 +18,18 @@ export async function action({ request, params }: ActionFunctionArgs) { } // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const rawBody = await request.json(); const body = InitializeDeploymentRequestBody.safeParse(rawBody); 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 42ef412ec1..5c01c37560 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts @@ -4,11 +4,14 @@ import { z } from "zod"; import { env as processEnv } from "~/env.server"; import { authenticatedEnvironmentForAuthentication, - authenticateRequest, branchNameFromRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; +import { + authenticateEnvironmentScopedApiRequest, + authorizePatEnvironmentAccess, + presentedApiKeyFromAuthentication, +} from "~/services/environmentVariableApiAccess.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -27,15 +30,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { projectRef, env } = parsedParams.data; try { - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + // 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"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -44,12 +45,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { branchNameFromRequest(request) ); - // This endpoint hands the caller the environment's secret key. For a PAT - // (a user), gate it on env-tier read:apiKeys — so a restricted role can't - // pull deployed credentials (and therefore can't deploy) via the CLI. + // 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, @@ -58,8 +63,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }); 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); + const result: GetProjectEnvResponse = { - apiKey: environment.apiKey, + apiKey: presentedApiKey ?? 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.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts index 4e0fca9a48..49ce15ae18 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts @@ -4,12 +4,14 @@ import { UpdateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; import { - authenticateRequest, authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; +import { + authenticateEnvVarApiRequest, + authorizeEnvVarApiRequest, +} from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -26,15 +28,11 @@ export async function action({ params, request }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "write"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -46,6 +44,10 @@ export async function action({ params, request }: ActionFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ 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, @@ -126,15 +128,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "read"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -146,6 +144,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ 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, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts index 42b225f4e1..0f14d5b0f4 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts @@ -4,11 +4,13 @@ import { ImportEnvironmentVariablesRequestBody } from "@trigger.dev/core/v3"; import { parse } from "dotenv"; import { z } from "zod"; import { - authenticateRequest, authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; -import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; +import { + authenticateEnvVarApiRequest, + authorizeEnvVarApiRequest, +} from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -23,15 +25,11 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "write"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -43,6 +41,10 @@ export async function action({ params, request }: ActionFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ 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, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index 49990204c1..97b4795c25 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -3,11 +3,13 @@ import { json } from "@remix-run/server-runtime"; import { CreateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { - authenticateRequest, authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; -import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; +import { + authenticateEnvVarApiRequest, + authorizeEnvVarApiRequest, +} from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -22,15 +24,11 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "write"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -42,6 +40,10 @@ export async function action({ params, request }: ActionFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ 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, @@ -85,15 +87,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "read"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -105,6 +103,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ 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, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts index 151c182e16..07945df9ba 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts @@ -1,7 +1,7 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { resolveVariablesForEnvironment } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -15,11 +15,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + // Reading env vars requires the read:envvars scope (root keys authorize + // everything). + const authResult = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "envvars" }, + }); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const { projectRef } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts index 3296e7fa78..aa31e6aba5 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts @@ -16,6 +16,10 @@ const route = createActionApiRoute( params: z.object({ queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), }), + authorization: { + action: "write", + resource: (params) => ({ type: "queues", id: params.queueParam }), + }, }, async ({ params, body, authentication }) => { const input: RetrieveQueueParam = diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts index 9a83f3ed84..633324e86d 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts @@ -15,6 +15,10 @@ const route = createActionApiRoute( params: z.object({ queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), }), + authorization: { + action: "write", + resource: (params) => ({ type: "queues", id: params.queueParam }), + }, }, async ({ params, body, authentication }) => { const input: RetrieveQueueParam = diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts index a0f910fe58..6c3c1f25dc 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts @@ -15,6 +15,10 @@ const route = createActionApiRoute( params: z.object({ queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), }), + authorization: { + action: "write", + resource: (params) => ({ type: "queues", id: params.queueParam }), + }, }, async ({ params, body, authentication }) => { const input: RetrieveQueueParam = diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts index a9bcd2342e..2f72805f62 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts @@ -15,6 +15,10 @@ export const loader = createLoaderApiRoute( }), searchParams: SearchParamsSchema, findResource: async () => 1, // This is a dummy function, we don't need to find a resource + authorization: { + action: "read", + resource: (_, params) => ({ type: "queues", id: params.queueParam }), + }, }, async ({ params, searchParams, authentication }) => { const input: RetrieveQueueParam = diff --git a/apps/webapp/app/routes/api.v1.queues.ts b/apps/webapp/app/routes/api.v1.queues.ts index b257a248a0..27fe2eb93d 100644 --- a/apps/webapp/app/routes/api.v1.queues.ts +++ b/apps/webapp/app/routes/api.v1.queues.ts @@ -24,6 +24,7 @@ export const loader = createLoaderApiRoute( { searchParams: SearchParamsSchema, findResource: async () => 1, // This is a dummy function, we don't need to find a resource + authorization: { action: "read", resource: () => ({ type: "queues" }) }, }, async ({ searchParams, authentication }) => { const service = new QueueListPresenter(searchParams.perPage); diff --git a/apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts b/apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts index df684946b1..f0bf949e35 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts @@ -1,13 +1,13 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { TaskRun } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { runStore } from "~/v3/runStore.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server"; import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server"; +import { resolveRunForMutation } from "~/v3/mollifier/resolveRunForMutation.server"; import { sanitizeTriggerSource } from "~/utils/triggerSource"; import { clientSafeErrorMessage } from "~/utils/prismaErrors"; @@ -49,108 +49,116 @@ const BufferedReplayInputSchema = z.object({ seedMetadataType: z.string().nullable().optional(), }); -export async function action({ request, params }: ActionFunctionArgs) { - // Ensure this is a POST request - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } - - // Authenticate the request - const authenticationResult = await authenticateApiRequest(request); - if (!authenticationResult) { - return json({ error: "Invalid or Missing API Key" }, { status: 401 }); - } - - const parsed = ParamsSchema.safeParse(params); - if (!parsed.success) { - return json({ error: "Invalid or missing run ID" }, { status: 400 }); - } - - const { runParam } = parsed.data; +const { action } = createActionApiRoute( + { + params: ParamsSchema, + method: "POST", + findResource: (params, authentication) => + resolveRunForMutation({ + runParam: params.runParam, + environmentId: authentication.environment.id, + organizationId: authentication.environment.organizationId, + }), + authorization: { + action: "write", + resource: (params, _, __, ___, run) => + run + ? anyResource([ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ]) + : { type: "runs", id: params.runParam }, + }, + }, + async ({ request, params, authentication }) => { + const { runParam } = params; - try { - const env = authenticationResult.environment; - // PG-first. Replay works on any status per audit — no - // filter beyond friendlyId is the existing semantic; findFirst with - // env scoping tightens it minimally without changing behaviour for - // a correctly-authed caller. - let taskRun: TaskRun | null = await runStore.findRun( - { - friendlyId: runParam, - runtimeEnvironmentId: env.id, - }, - prisma - ); + try { + const env = authentication.environment; + // PG-first. Replay works on any status per audit — no + // filter beyond friendlyId is the existing semantic; findFirst with + // env scoping tightens it minimally without changing behaviour for + // a correctly-authed caller. + let taskRun: TaskRun | null = await runStore.findRun( + { + friendlyId: runParam, + runtimeEnvironmentId: env.id, + }, + prisma + ); - if (!taskRun) { - // Buffered fallback. SyntheticRun carries every field - // ReplayTaskRunService reads from a TaskRun. Validate the subset of - // fields the service consumes (BufferedReplayInputSchema above) - // before casting; a schema mismatch surfaces as a 404 here rather - // than as a silent undefined deep inside the service. - const buffered = await findRunByIdWithMollifierFallback({ - runId: runParam, - environmentId: env.id, - organizationId: env.organizationId, - }); - if (buffered) { - const parsed = BufferedReplayInputSchema.safeParse(buffered); - if (parsed.success) { - // Manual sync point: `BufferedReplayInputSchema` covers only - // the subset of `TaskRun` fields `ReplayTaskRunService.call` - // currently reads from `existingTaskRun`. The cast is `as - // unknown as TaskRun` because the full `TaskRun` type carries - // ~40 fields the service never touches; mirroring all of them - // on a synthetic snapshot would be misleading. If a future - // change to `ReplayTaskRunService` reads an additional - // `existingTaskRun` field, **add it to the schema above** — - // otherwise the buffered path will silently feed the service - // `undefined` for that field while the PG-source replay - // works. The `safeParse` + warn-log + 404 below is the - // run-time fail-safe; this comment is the design fail-safe. - taskRun = parsed.data as unknown as TaskRun; - } else { - logger.warn("replay: buffered fallback failed schema validation", { - runParam, - issues: parsed.error.issues.map((issue) => ({ - path: issue.path.join("."), - code: issue.code, - })), - }); + if (!taskRun) { + // Buffered fallback. SyntheticRun carries every field + // ReplayTaskRunService reads from a TaskRun. Validate the subset of + // fields the service consumes (BufferedReplayInputSchema above) + // before casting; a schema mismatch surfaces as a 404 here rather + // than as a silent undefined deep inside the service. + const buffered = await findRunByIdWithMollifierFallback({ + runId: runParam, + environmentId: env.id, + organizationId: env.organizationId, + }); + if (buffered) { + const parsed = BufferedReplayInputSchema.safeParse(buffered); + if (parsed.success) { + // Manual sync point: `BufferedReplayInputSchema` covers only + // the subset of `TaskRun` fields `ReplayTaskRunService.call` + // currently reads from `existingTaskRun`. The cast is `as + // unknown as TaskRun` because the full `TaskRun` type carries + // ~40 fields the service never touches; mirroring all of them + // on a synthetic snapshot would be misleading. If a future + // change to `ReplayTaskRunService` reads an additional + // `existingTaskRun` field, **add it to the schema above** — + // otherwise the buffered path will silently feed the service + // `undefined` for that field while the PG-source replay + // works. The `safeParse` + warn-log + 404 below is the + // run-time fail-safe; this comment is the design fail-safe. + taskRun = parsed.data as unknown as TaskRun; + } else { + logger.warn("replay: buffered fallback failed schema validation", { + runParam, + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + })), + }); + } } } - } - if (!taskRun) { - return json({ error: "Run not found" }, { status: 404 }); - } + if (!taskRun) { + return json({ error: "Run not found" }, { status: 404 }); + } - const triggerSource = sanitizeTriggerSource(request.headers.get("x-trigger-source")) ?? "api"; + const triggerSource = sanitizeTriggerSource(request.headers.get("x-trigger-source")) ?? "api"; - const service = new ReplayTaskRunService(); - const newRun = await service.call(taskRun, { triggerSource }); + const service = new ReplayTaskRunService(); + const newRun = await service.call(taskRun, { triggerSource }); - if (!newRun) { - return json({ error: "Failed to create new run" }, { status: 400 }); - } + if (!newRun) { + return json({ error: "Failed to create new run" }, { status: 400 }); + } - return json({ - id: newRun?.friendlyId, - }); - } catch (error) { - if (error instanceof Error) { - logger.error("Failed to replay run", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - run: runParam, + return json({ + id: newRun?.friendlyId, }); - return json({ error: clientSafeErrorMessage(error) }, { status: 400 }); - } else { - logger.error("Failed to replay run", { error: JSON.stringify(error), run: runParam }); - return json({ error: JSON.stringify(error) }, { status: 400 }); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to replay run", { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + run: runParam, + }); + return json({ error: clientSafeErrorMessage(error) }, { status: 400 }); + } else { + logger.error("Failed to replay run", { error: JSON.stringify(error), run: runParam }); + return json({ error: JSON.stringify(error) }, { status: 400 }); + } } } -} +); + +export { action }; diff --git a/apps/webapp/app/routes/api.v1.runs.ts b/apps/webapp/app/routes/api.v1.runs.ts index a4f543e68f..dca246a0c2 100644 --- a/apps/webapp/app/routes/api.v1.runs.ts +++ b/apps/webapp/app/routes/api.v1.runs.ts @@ -3,7 +3,11 @@ import { ApiRunListPresenter, ApiRunListSearchParams, } from "~/presenters/v3/ApiRunListPresenter.server"; -import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, + everyResource, +} from "~/services/routeBuilders/apiBuilder.server"; export const loader = createLoaderApiRoute( { @@ -18,17 +22,18 @@ export const loader = createLoaderApiRoute( // and the legacy `checkAuthorization` iterated `Object.keys` — so a // JWT with type-level `read:tasks` (no id) granted access to the // unfiltered runs list. The new ability model only matches against - // resources we list, so the type-level `{ type: "tasks" }` element - // (alongside `{ type: "runs" }` and the per-id task elements) - // preserves that semantic — `read:tasks` JWTs in the wild still - // list unfiltered runs without needing a separate `read:runs` - // scope. Per-id `read:tasks:foo` still grants only when the - // filter includes `foo`. - return anyResource([ - { type: "runs" }, - { type: "tasks" }, - ...taskFilter.map((id) => ({ type: "tasks", id })), - ]); + // resources we list. Keep type-level runs/tasks as alternatives so + // broad scopes retain that behavior. ID-scoped keys, however, must + // match every task in a multi-task filter; matching one item must not + // expose the others. + if (taskFilter.length === 0) { + return anyResource([{ type: "runs" }, { type: "tasks" }]); + } + + return everyResource( + taskFilter.map((id) => ({ type: "tasks", id })), + [{ type: "runs" }, { type: "tasks" }] + ); }, }, findResource: async () => 1, // This is a dummy function, we don't need to find a resource diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index 1a10e6c6f0..c18412c481 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -27,6 +27,7 @@ import { anyResource, createActionApiRoute, createLoaderApiRoute, + everyResource, } from "~/services/routeBuilders/apiBuilder.server"; import { ServiceValidationError } from "~/v3/services/common.server"; import { runStore } from "~/v3/runStore.server"; @@ -48,15 +49,19 @@ export const loader = createLoaderApiRoute( // - Type-level `read:sessions` (the old superScope) matches the // sessions element (collection-level — no id) // - `read:all` / `admin` bypass via the JWT ability's wildcard branches - // The taskIdentifier filter accepts a string or an array; expand to - // one resource per task id so any per-task-scoped JWT among them - // grants access (the array gets OR semantics). + // The taskIdentifier filter accepts a string or an array. Broad + // sessions/tasks scopes remain alternatives, while ID-scoped keys must + // match every requested task so one allowed filter cannot expose others. resource: (_, __, searchParams) => { const taskFilter = asArray(searchParams["filter[taskIdentifier]"]) ?? []; - return anyResource([ - ...taskFilter.map((id) => ({ type: "tasks" as const, id })), - { type: "sessions" as const }, - ]); + if (taskFilter.length === 0) { + return anyResource([{ type: "sessions" as const }, { type: "tasks" as const }]); + } + + return everyResource( + taskFilter.map((id) => ({ type: "tasks" as const, id })), + [{ type: "sessions" as const }, { type: "tasks" as const }] + ); }, }, findResource: async () => 1, diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts index e57092bc66..2bd7bc5650 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts @@ -1,138 +1,134 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { BatchTriggerTaskV2RequestBody } from "@trigger.dev/core/v3"; import { BatchTriggerTaskRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { fromZodError } from "zod-validation-error"; import { MAX_BATCH_TRIGGER_ITEMS } from "~/consts"; +import { canWriteParentRun } from "~/utils/parentRunAuthorization.server"; import { clientSafeErrorMessage } from "~/utils/prismaErrors"; import { env } from "~/env.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { BatchTriggerV3Service } from "~/v3/services/batchTriggerV3.server"; import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger"; import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server"; +import { publicAccessTokenResponseHeaders } from "~/services/publicAccessTokenResponse.server"; const ParamsSchema = z.object({ taskId: z.string(), }); -export async function action({ request, params }: ActionFunctionArgs) { - // Ensure this is a POST request - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } - - // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const rawHeaders = Object.fromEntries(request.headers); - - const headers = HeadersSchema.safeParse(rawHeaders); - - if (!headers.success) { - return json({ error: "Invalid headers" }, { status: 400 }); - } - - const { - "idempotency-key": idempotencyKey, - "trigger-version": triggerVersion, - "x-trigger-span-parent-as-link": spanParentAsLink, - "x-trigger-worker": isFromWorker, - "x-trigger-realtime-streams-version": realtimeStreamsVersion, - traceparent, - tracestate, - } = headers.data; - - const { taskId } = ParamsSchema.parse(params); - - const contentLength = request.headers.get("content-length"); - - if (!contentLength || parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) { - return json({ error: "Request body too large" }, { status: 413 }); - } +const { action } = createActionApiRoute( + { + params: ParamsSchema, + headers: HeadersSchema, + body: BatchTriggerTaskRequestBody, + method: "POST", + maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE, + authorization: { + action: "batchTrigger", + resource: (params) => ({ type: "tasks", id: params.taskId }), + }, + }, + async ({ params, headers, body, authentication, ability }) => { + const { taskId } = params; + const { + "idempotency-key": idempotencyKey, + "trigger-version": triggerVersion, + "x-trigger-span-parent-as-link": spanParentAsLink, + "x-trigger-worker": isFromWorker, + "x-trigger-realtime-streams-version": realtimeStreamsVersion, + traceparent, + tracestate, + } = headers; + + logger.debug("Triggering batch", { + taskId, + idempotencyKey, + triggerVersion, + body, + }); - // Now parse the request body - const anyBody = await request.json(); + if (!body.items.length) { + return json({ error: "No items to trigger" }, { status: 400 }); + } - const body = BatchTriggerTaskRequestBody.safeParse(anyBody); + // Check the there are fewer than 100 items. This has to stay above the + // parent-run authorization below, which costs one lookup per distinct + // parent — an oversized batch must be rejected before it can spend them. + if (body.items.length > MAX_BATCH_TRIGGER_ITEMS) { + return json( + { + error: `Too many items. Maximum allowed batch size is ${MAX_BATCH_TRIGGER_ITEMS}.`, + }, + { status: 400 } + ); + } - if (!body.success) { - return json( - { error: fromZodError(body.error, { prefix: "Invalid batchTrigger call" }).toString() }, - { status: 400 } + const parentRunIds = Array.from( + new Set(body.items.map((item) => item.options?.parentRunId).filter((id) => id !== undefined)) ); - } - - logger.debug("Triggering batch", { - taskId, - idempotencyKey, - triggerVersion, - body: body.data, - }); - - if (!body.data.items.length) { - return json({ error: "No items to trigger" }, { status: 400 }); - } - - // Check the there are fewer than 100 items - if (body.data.items.length > MAX_BATCH_TRIGGER_ITEMS) { - return json( - { - error: `Too many items. Maximum allowed batch size is ${MAX_BATCH_TRIGGER_ITEMS}.`, - }, - { status: 400 } + const canWriteParentRuns = await Promise.all( + parentRunIds.map((parentRunId) => + canWriteParentRun( + ability, + authentication.environment.id, + authentication.environment.organizationId, + parentRunId + ) + ) ); - } + if (canWriteParentRuns.some((allowed) => !allowed)) { + return json({ error: "Unauthorized" }, { status: 403 }); + } - const service = new BatchTriggerV3Service(); + const service = new BatchTriggerV3Service(); - const traceContext = - traceparent && isFromWorker // If the request is from a worker, we should pass the trace context - ? { traceparent, tracestate } - : undefined; + const traceContext = + traceparent && isFromWorker // If the request is from a worker, we should pass the trace context + ? { traceparent, tracestate } + : undefined; - const v3Body = convertV1BodyToV2Body(body.data, taskId); + const v3Body = convertV1BodyToV2Body(body, taskId); - try { - const result = await service.call(authenticationResult.environment, v3Body, { - idempotencyKey: idempotencyKey ?? undefined, - triggerVersion: triggerVersion ?? undefined, - traceContext, - spanParentAsLink: spanParentAsLink === 1, - realtimeStreamsVersion: determineRealtimeStreamsVersion(realtimeStreamsVersion ?? undefined), - }); + try { + const result = await service.call(authentication.environment, v3Body, { + idempotencyKey: idempotencyKey ?? undefined, + triggerVersion: triggerVersion ?? undefined, + traceContext, + spanParentAsLink: spanParentAsLink === 1, + realtimeStreamsVersion: determineRealtimeStreamsVersion( + realtimeStreamsVersion ?? undefined + ), + }); - if (!result) { - return json({ error: "Task not found" }, { status: 404 }); - } + if (!result) { + return json({ error: "Task not found" }, { status: 404 }); + } - return json( - { - batchId: result.id, - runs: result.runs.map((run) => run.id), - }, - { - headers: { - "x-trigger-jwt-claims": JSON.stringify({ - sub: authenticationResult.environment.id, - pub: true, - }), + const $responseHeaders = await publicAccessTokenResponseHeaders({ + environment: authentication.environment, + scopes: [`read:batch:${result.id}`], + expirationTime: "1h", + }); + + return json( + { + batchId: result.id, + runs: result.runs.map((run) => run.id), }, + { headers: $responseHeaders } + ); + } catch (error) { + if (error instanceof Error) { + return json({ error: clientSafeErrorMessage(error) }, { status: 400 }); } - ); - } catch (error) { - if (error instanceof Error) { - return json({ error: clientSafeErrorMessage(error) }, { status: 400 }); - } - return json({ error: "Something went wrong" }, { status: 500 }); + return json({ error: "Something went wrong" }, { status: 500 }); + } } -} +); + +export { action }; // Strip from options: // - dependentBatch diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index 9d1e4c5b8d..6165049330 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -20,6 +20,8 @@ import { handleRequestIdempotency, saveRequestIdempotency, } from "~/utils/requestIdempotency.server"; +import { scopeRequestIdempotencyKey } from "~/utils/requestIdempotencyKey"; +import { canWriteParentRun } from "~/utils/parentRunAuthorization.server"; import { sanitizeTriggerSource } from "~/utils/triggerSource"; import { runStore } from "~/v3/runStore.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -60,7 +62,7 @@ const { action, loader } = createActionApiRoute( }, corsStrategy: "all", }, - async ({ body, headers, params, authentication }) => { + async ({ body, headers, params, authentication, ability }) => { const { "idempotency-key": idempotencyKey, "idempotency-key-ttl": idempotencyKeyTTL, @@ -76,7 +78,22 @@ const { action, loader } = createActionApiRoute( "x-trigger-source": triggerSourceHeader, } = headers; - const cachedResponse = await handleRequestIdempotency(requestIdempotencyKey, { + if ( + !(await canWriteParentRun( + ability, + authentication.environment.id, + authentication.environment.organizationId, + body.options?.parentRunId + )) + ) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + + const scopedIdempotencyKey = scopeRequestIdempotencyKey(requestIdempotencyKey, [ + authentication.environment.id, + params.taskId, + ]); + const cachedResponse = await handleRequestIdempotency(scopedIdempotencyKey, { requestType: "trigger", findCachedEntity: async (cachedRequestId) => { return await runStore.findRun( @@ -153,7 +170,7 @@ const { action, loader } = createActionApiRoute( // materialisation: once the run lands in PG, normal request- // idempotency from that point forward works as usual. if (!result.isMollified) { - await saveRequestIdempotency(requestIdempotencyKey, "trigger", result.run.id); + await saveRequestIdempotency(scopedIdempotencyKey, "trigger", result.run.id); } const $responseHeaders = await responseHeaders(result.run, authentication); diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index a43e0d3af5..5c9202d6fe 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -1,8 +1,6 @@ import { json } from "@remix-run/server-runtime"; -import type { BatchTriggerTaskV2Response } from "@trigger.dev/core/v3"; -import { BatchTriggerTaskV2RequestBody, generateJWT } from "@trigger.dev/core/v3"; +import { BatchTriggerTaskV2RequestBody } from "@trigger.dev/core/v3"; import { env } from "~/env.server"; -import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { getOneTimeUseToken } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { createActionApiRoute, everyResource } from "~/services/routeBuilders/apiBuilder.server"; @@ -16,7 +14,7 @@ import { OutOfEntitlementError } from "~/v3/services/triggerTask.server"; import { sanitizeTriggerSource } from "~/utils/triggerSource"; import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger"; import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server"; -import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server"; +import { publicAccessTokenResponseHeaders } from "~/services/publicAccessTokenResponse.server"; const { action, loader } = createActionApiRoute( { @@ -124,11 +122,11 @@ const { action, loader } = createActionApiRoute( triggerAction: "trigger", }); - const $responseHeaders = await responseHeaders( - batch, - authentication.environment, - triggerClient - ); + const $responseHeaders = await publicAccessTokenResponseHeaders({ + environment: authentication.environment, + scopes: [`read:batch:${batch.id}`], + expirationTime: "1h", + }); return json(batch, { status: 202, headers: $responseHeaders }); } catch (error) { @@ -163,38 +161,4 @@ const { action, loader } = createActionApiRoute( } ); -async function responseHeaders( - batch: BatchTriggerTaskV2Response, - environment: AuthenticatedEnvironment, - triggerClient?: string | null -): Promise> { - const claimsHeader = JSON.stringify({ - sub: environment.id, - pub: true, - }); - - if (triggerClient === "browser") { - const claims = { - sub: environment.id, - pub: true, - scopes: [`read:batch:${batch.id}`], - }; - - const jwt = await generateJWT({ - secretKey: extractJwtSigningSecretKey(environment), - payload: claims, - expirationTime: "1h", - }); - - return { - "x-trigger-jwt-claims": claimsHeader, - "x-trigger-jwt": jwt, - }; - } - - return { - "x-trigger-jwt-claims": claimsHeader, - }; -} - export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts index ace6128ac7..62322c527c 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts @@ -15,10 +15,10 @@ import { runOpsSplitReadEnabled, type PrismaClientOrTransaction, } from "~/db.server"; -import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { resolveRunIdMintKind } from "~/v3/engineVersion.server"; import { logger } from "~/services/logger.server"; import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; +import { publicAccessTokenResponseHeaders } from "~/services/publicAccessTokenResponse.server"; import { createActionApiRoute, createLoaderApiRoute, @@ -103,11 +103,16 @@ const { action } = createActionApiRoute( standaloneResidency: residency, }); - const $responseHeaders = await responseHeaders(authentication.environment); + const waitpointId = WaitpointId.toFriendlyId(result.waitpoint.id); + const $responseHeaders = await publicAccessTokenResponseHeaders({ + environment: authentication.environment, + scopes: [`write:waitpoints:${waitpointId}`], + expirationTime: "24h", + }); return json( { - id: WaitpointId.toFriendlyId(result.waitpoint.id), + id: waitpointId, isCached: result.isCached, url: generateHttpCallbackUrl(result.waitpoint.id, authentication.environment.apiKey), }, @@ -124,17 +129,4 @@ const { action } = createActionApiRoute( } ); -async function responseHeaders( - environment: AuthenticatedEnvironment -): Promise> { - const claimsHeader = JSON.stringify({ - sub: environment.id, - pub: true, - }); - - return { - "x-trigger-jwt-claims": claimsHeader, - }; -} - export { action }; diff --git a/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts b/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts index 0fece044fb..b7e6bdc2f2 100644 --- a/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts +++ b/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { FinalizeDeploymentV2Service } from "~/v3/services/finalizeDeploymentV2.server"; @@ -24,13 +24,18 @@ export async function action({ request, params }: ActionFunctionArgs) { } // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts index cb660049c9..18a5d2cc30 100644 --- a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts +++ b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts @@ -1,7 +1,7 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { env as appEnv } from "~/env.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server"; import { mutateWithFallback } from "~/v3/mollifier/mutateWithFallback.server"; @@ -19,10 +19,6 @@ const { action } = createActionApiRoute( params: ParamsSchema, allowJWT: true, corsStrategy: "none", - authorization: { - action: "write", - resource: (params) => ({ type: "runs", id: params.runParam }), - }, // PG-or-buffer resolver. Returning null here would 404 BEFORE the // action runs (`apiBuilder.server.ts:321`), so buffered cancels need // a buffer check at this layer too. Logic lives in a helper so the @@ -36,6 +32,16 @@ const { action } = createActionApiRoute( environmentId: auth.environment.id, organizationId: auth.environment.organizationId, }), + authorization: { + action: "write", + resource: (params, _, __, ___, run) => + run + ? anyResource([ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ]) + : { type: "runs", id: params.runParam }, + }, }, async ({ params, authentication }) => { const runId = params.runParam; diff --git a/apps/webapp/app/routes/api.v2.tasks.batch.ts b/apps/webapp/app/routes/api.v2.tasks.batch.ts index c766bb474f..5dcbf13e0f 100644 --- a/apps/webapp/app/routes/api.v2.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v2.tasks.batch.ts @@ -12,6 +12,8 @@ import { handleRequestIdempotency, saveRequestIdempotency, } from "~/utils/requestIdempotency.server"; +import { scopeRequestIdempotencyKey } from "~/utils/requestIdempotencyKey"; +import { canWriteParentRun } from "~/utils/parentRunAuthorization.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { BatchProcessingStrategy } from "~/v3/services/batchTriggerV3.server"; import { OutOfEntitlementError } from "~/v3/services/triggerTask.server"; @@ -45,11 +47,22 @@ const { action, loader } = createActionApiRoute( }, corsStrategy: "all", }, - async ({ body, headers, params, authentication }) => { + async ({ body, headers, params, authentication, ability }) => { if (!body.items.length) { return json({ error: "Batch cannot be triggered with no items" }, { status: 400 }); } + if ( + !(await canWriteParentRun( + ability, + authentication.environment.id, + authentication.environment.organizationId, + body.parentRunId + )) + ) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + // Check the there are fewer than MAX_BATCH_V2_TRIGGER_ITEMS items if (body.items.length > env.MAX_BATCH_V2_TRIGGER_ITEMS) { return json( @@ -87,7 +100,11 @@ const { action, loader } = createActionApiRoute( requestIdempotencyKey, }); - const cachedResponse = await handleRequestIdempotency(requestIdempotencyKey, { + const scopedIdempotencyKey = scopeRequestIdempotencyKey(requestIdempotencyKey, [ + authentication.environment.id, + ...Array.from(new Set(body.items.map((item) => item.task))).sort(), + ]); + const cachedResponse = await handleRequestIdempotency(scopedIdempotencyKey, { requestType: "batch-trigger", findCachedEntity: async (cachedRequestId) => { const batch = await runStore.findBatchTaskRunById(cachedRequestId); @@ -99,7 +116,7 @@ const { action, loader } = createActionApiRoute( runCount: cachedBatch.runCount, }), buildResponseHeaders: async (responseBody, cachedEntity) => { - return await responseHeaders(responseBody, authentication.environment, triggerClient); + return await responseHeaders(responseBody, authentication.environment); }, }); @@ -116,7 +133,7 @@ const { action, loader } = createActionApiRoute( const service = new RunEngineBatchTriggerService(batchProcessingStrategy ?? undefined); service.onBatchTaskRunCreated.attachOnce(async (batch) => { - await saveRequestIdempotency(requestIdempotencyKey, "batch-trigger", batch.id); + await saveRequestIdempotency(scopedIdempotencyKey, "batch-trigger", batch.id); }); try { @@ -132,11 +149,7 @@ const { action, loader } = createActionApiRoute( triggerAction: "trigger", }); - const $responseHeaders = await responseHeaders( - batch, - authentication.environment, - triggerClient - ); + const $responseHeaders = await responseHeaders(batch, authentication.environment); return json(batch, { status: 202, @@ -176,35 +189,23 @@ const { action, loader } = createActionApiRoute( async function responseHeaders( batch: BatchTriggerTaskV3Response, - environment: AuthenticatedEnvironment, - triggerClient?: string | null + environment: AuthenticatedEnvironment ): Promise> { - const claimsHeader = JSON.stringify({ + const claims = { sub: environment.id, pub: true, - }); - - if (triggerClient === "browser") { - const claims = { - sub: environment.id, - pub: true, - scopes: [`read:batch:${batch.id}`], - }; - - const jwt = await generateJWT({ - secretKey: extractJwtSigningSecretKey(environment), - payload: claims, - expirationTime: "1h", - }); + scopes: [`read:batch:${batch.id}`], + }; - return { - "x-trigger-jwt-claims": claimsHeader, - "x-trigger-jwt": jwt, - }; - } + const jwt = await generateJWT({ + secretKey: extractJwtSigningSecretKey(environment), + payload: claims, + expirationTime: "1h", + }); return { - "x-trigger-jwt-claims": claimsHeader, + "x-trigger-jwt-claims": JSON.stringify({ sub: environment.id, pub: true }), + "x-trigger-jwt": jwt, }; } diff --git a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts index c34dbc178c..9e2bd51025 100644 --- a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts +++ b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts @@ -6,8 +6,12 @@ import { createNdjsonParserStream, streamToAsyncIterable, } from "~/runEngine/services/streamBatchItems.server"; -import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { rbac } from "~/services/rbac.server"; +import { + authorizedBatchItemStream, + BatchItemAuthorizationError, +} from "~/utils/batchItemAuthorization"; import { ServiceValidationError } from "~/v3/services/baseService.server"; const ParamsSchema = z.object({ @@ -48,13 +52,22 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } - // Authenticate the request - const authResult = await authenticateApiRequestWithFailure(request, { - allowPublicKey: true, - }); + // This streaming route cannot use createActionApiRoute because the body must + // remain an unread stream. Use the same RBAC controller directly and apply + // its ability to every parsed item below. + // + // Because we bypass the route builder, we also bypass its + // `restrictedApiKey && !authorization -> 403` fail-closed. Authorization is + // instead enforced per item by `authorizedBatchItemStream` below, which also + // requires a restricted credential to present at least one authorized item + // before the service may touch the batch — otherwise an empty stream would + // reach it having passed no checks at all. Any logic added between here and + // that call runs authenticated but NOT authorized, so keep new per-request + // work behind it. + const authResult = await rbac.authenticateBearer(request, { allowJWT: true }); if (!authResult.ok) { - return json({ error: authResult.error }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } // Get the request body stream @@ -76,8 +89,14 @@ export async function action({ request, params }: ActionFunctionArgs) { // Pipe the request body through the parser const parsedStream = body.pipeThrough(parser); - // Convert to async iterable for the service - const itemsIterator = streamToAsyncIterable(parsedStream); + // Convert to async iterable for the service. This authorizes the first item + // eagerly, so a stream that yields no items is rejected before the service + // can report anything about the batch. + const itemsIterator = await authorizedBatchItemStream( + streamToAsyncIterable(parsedStream), + authResult.ability, + batchId + ); // Process the stream const service = new StreamBatchItemsService(); @@ -88,6 +107,10 @@ export async function action({ request, params }: ActionFunctionArgs) { return json(result, { status: 200 }); } catch (error) { + if (error instanceof BatchItemAuthorizationError) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + // Customer-facing validation failures (invalid item shape, invalid JSON // in the streamed body). The handler returns 4xx with the message; // system handles it gracefully, no alert needed. diff --git a/apps/webapp/app/routes/api.v3.batches.ts b/apps/webapp/app/routes/api.v3.batches.ts index 3179e2fa6a..071bb783b8 100644 --- a/apps/webapp/app/routes/api.v3.batches.ts +++ b/apps/webapp/app/routes/api.v3.batches.ts @@ -5,16 +5,24 @@ import { env } from "~/env.server"; import { BatchRateLimitExceededError } from "~/runEngine/concerns/batchLimits.server"; import { CreateBatchService } from "~/runEngine/services/createBatch.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import type { RbacAbility } from "@trigger.dev/rbac"; import { getOneTimeUseToken } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server"; import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createActionApiRoute, + everyResource, +} from "~/services/routeBuilders/apiBuilder.server"; +import { batchPublicAccessScopes } from "~/utils/batchItemAuthorization"; +import { canWriteParentRun } from "~/utils/parentRunAuthorization.server"; import { clientSafeErrorMessage } from "~/utils/prismaErrors"; import { handleRequestIdempotency, saveRequestIdempotency, } from "~/utils/requestIdempotency.server"; +import { scopeRequestIdempotencyKey } from "~/utils/requestIdempotencyKey"; import { sanitizeTriggerSource } from "~/utils/triggerSource"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { OutOfEntitlementError } from "~/v3/services/triggerTask.server"; @@ -37,18 +45,41 @@ const { action, loader } = createActionApiRoute( maxContentLength: 131_072, // 128KB is plenty for the batch metadata authorization: { action: "batchTrigger", - // No specific tasks to authorize at batch creation time — tasks are - // validated when items are streamed. Collection-level check. - resource: () => ({ type: "tasks" }), + resource: (_params, _searchParams, _headers, body) => { + // Newer clients declare the distinct task identifiers before creating + // the batch, so selected-task credentials can be authorized before the + // shell is created. Older clients omit them and retain the existing + // collection-level behavior: broad credentials pass, selected-task + // credentials fail closed. + if (!body.taskIdentifiers) { + return anyResource([{ type: "batch" }, { type: "tasks" }]); + } + + return everyResource( + body.taskIdentifiers.map((id) => ({ type: "tasks" as const, id })), + [{ type: "batch" }, { type: "tasks" }] + ); + }, }, corsStrategy: "all", }, - async ({ body, headers, authentication }) => { + async ({ body, headers, authentication, ability }) => { // Validate runCount if (body.runCount <= 0) { return json({ error: "runCount must be a positive integer" }, { status: 400 }); } + if ( + !(await canWriteParentRun( + ability, + authentication.environment.id, + authentication.environment.organizationId, + body.parentRunId + )) + ) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + // Check runCount against limit if (body.runCount > env.STREAMING_BATCH_MAX_ITEMS) { return json( @@ -82,11 +113,18 @@ const { action, loader } = createActionApiRoute( triggerClient, }); - // Handle idempotency for the batch creation + // Keep create-batch retries isolated by environment and the task set that + // was authorized above. Sorting makes the scope stable when callers send + // the same identifiers in a different order. + const scopedIdempotencyKey = scopeRequestIdempotencyKey(body.idempotencyKey, [ + authentication.environment.id, + ...(body.taskIdentifiers ? [...new Set(body.taskIdentifiers)].sort() : []), + ]); + const cachedResponse = await handleRequestIdempotency< { friendlyId: string; runCount: number }, CreateBatchResponse - >(body.idempotencyKey, { + >(scopedIdempotencyKey, { requestType: "create-batch", findCachedEntity: async (cachedRequestId) => { const batch = await engine.runStore.findBatchTaskRunById(cachedRequestId); @@ -99,7 +137,12 @@ const { action, loader } = createActionApiRoute( isCached: true, }), buildResponseHeaders: async (responseBody) => { - return await responseHeaders(responseBody, authentication.environment, triggerClient); + return await responseHeaders( + responseBody, + authentication.environment, + ability, + triggerClient + ); }, }); @@ -114,7 +157,7 @@ const { action, loader } = createActionApiRoute( const service = new CreateBatchService(); service.onBatchTaskRunCreated.attachOnce(async (batch) => { - await saveRequestIdempotency(body.idempotencyKey, "create-batch", batch.id); + await saveRequestIdempotency(scopedIdempotencyKey, "create-batch", batch.id); }); try { @@ -132,6 +175,7 @@ const { action, loader } = createActionApiRoute( const $responseHeaders = await responseHeaders( batch, authentication.environment, + ability, triggerClient ); @@ -198,34 +242,27 @@ const { action, loader } = createActionApiRoute( async function responseHeaders( batch: CreateBatchResponse, environment: AuthenticatedEnvironment, + ability: RbacAbility, triggerClient?: string | null ): Promise> { - const claimsHeader = JSON.stringify({ - sub: environment.id, - pub: true, - }); + // Browser clients need a delegated token for phase two because they must not + // retain a private API key. Selected-task credentials only receive read access + // and must continue to authorize each streamed item with their private key. + const scopes = batchPublicAccessScopes(batch.id, ability, triggerClient === "browser"); - if (triggerClient === "browser") { - const claims = { + const jwt = await generateJWT({ + secretKey: extractJwtSigningSecretKey(environment), + payload: { sub: environment.id, pub: true, - scopes: [`read:batch:${batch.id}`, `write:batch:${batch.id}`], - }; - - const jwt = await generateJWT({ - secretKey: extractJwtSigningSecretKey(environment), - payload: claims, - expirationTime: "1h", - }); - - return { - "x-trigger-jwt-claims": claimsHeader, - "x-trigger-jwt": jwt, - }; - } + scopes, + }, + expirationTime: "1h", + }); return { - "x-trigger-jwt-claims": claimsHeader, + "x-trigger-jwt-claims": JSON.stringify({ sub: environment.id, pub: true }), + "x-trigger-jwt": jwt, }; } diff --git a/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts b/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts index 87fff8bbf7..2fdcc0de21 100644 --- a/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts +++ b/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { FinalizeDeploymentV2Service } from "~/v3/services/finalizeDeploymentV2.server"; @@ -24,13 +24,18 @@ export async function action({ request, params }: ActionFunctionArgs) { } // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v3.runs.$runId.ts b/apps/webapp/app/routes/api.v3.runs.$runId.ts index 5debce7035..0e7cecbbe0 100644 --- a/apps/webapp/app/routes/api.v3.runs.$runId.ts +++ b/apps/webapp/app/routes/api.v3.runs.$runId.ts @@ -31,9 +31,9 @@ export const loader = createLoaderApiRoute( }, }, }, - async ({ authentication, resource, apiVersion }) => { + async ({ authentication, ability, resource, apiVersion }) => { const presenter = new ApiRetrieveRunPresenter(apiVersion); - const result = await presenter.call(resource, authentication.environment); + const result = await presenter.call(resource, authentication.environment, ability); if (!result) { return json( diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 899b693a98..f485a6bb70 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -9,9 +9,11 @@ import { authIncludeBase, authIncludeWithParent, findEnvironmentByApiKey, + findEnvironmentByApiKeyWithResolution, findEnvironmentByPublicApiKey, toAuthenticated, } from "~/models/runtimeEnvironment.server"; +import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { logger } from "./logger.server"; import { safeEnvironmentLogFields } from "./safeEnvironmentLog"; @@ -28,6 +30,8 @@ import { } from "./organizationAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { rbac } from "./rbac.server"; +import { observeLegacyBearerAuthentication } from "~/services/authTelemetry.server"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), @@ -58,6 +62,10 @@ export type ApiAuthenticationResultSuccess = { realtime?: { skipColumns?: string[]; }; + // Present when authentication went through the RBAC bearer controller. + // Legacy direct authentication intentionally omits it and remains fail-closed + // for restricted additional keys. + ability?: RbacAbility; // Present when the request used a public JWT minted from a PAT/UAT exchange // that stamped an `act` delegation claim. `actor.sub` is the acting user id, // used for attribution (e.g. who resolved an error). Absent for plain env @@ -85,9 +93,9 @@ export async function authenticateApiRequest( return; } - const authentication = await authenticateApiKey(apiKey, { ...options, branchName }); - - return authentication; + return observeLegacyBearerAuthentication(request, () => + authenticateApiKey(apiKey, { ...options, branchName }) + ); } /** @@ -107,9 +115,9 @@ export async function authenticateApiRequestWithFailure( }; } - const authentication = await authenticateApiKeyWithFailure(apiKey, { ...options, branchName }); - - return authentication; + return observeLegacyBearerAuthentication(request, () => + authenticateApiKeyWithFailure(apiKey, { ...options, branchName }) + ); } /** @@ -117,7 +125,11 @@ export async function authenticateApiRequestWithFailure( */ export async function authenticateApiKey( apiKey: string, - options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {} + options: { + allowPublicKey?: boolean; + allowJWT?: boolean; + branchName?: string; + } = {} ): Promise { const result = getApiKeyResult(apiKey); @@ -184,7 +196,11 @@ export async function authenticateApiKey( */ async function authenticateApiKeyWithFailure( apiKey: string, - options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {} + options: { + allowPublicKey?: boolean; + allowJWT?: boolean; + branchName?: string; + } = {} ): Promise { const result = getApiKeyResult(apiKey); @@ -226,18 +242,24 @@ async function authenticateApiKeyWithFailure( }; } case "PRIVATE": { - const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName); - if (!environment) { + const resolution = await findEnvironmentByApiKeyWithResolution( + result.apiKey, + options.branchName + ); + if (!resolution.ok) { return { ok: false, - error: "Invalid API Key", + error: + resolution.reason === "restricted" + ? "This endpoint does not support restricted API keys. Use an API key with full environment access." + : "Invalid API Key", }; } return { ok: true, ...result, - environment, + environment: resolution.environment, }; } case "PUBLIC_JWT": { @@ -260,6 +282,52 @@ async function authenticateApiKeyWithFailure( } } +/** + * 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 + * authorizes `action` on `resource`. Root keys (and grace-window root keys) + * carry the unrestricted `admin` ability, preserving pre-granular behavior. + * + * Only apiKey credentials are accepted (no PAT / org token / public key). Use + * this for routes previously guarded by a bare `authenticateApiRequest` call. + */ +export async function authenticateApiKeyWithScope( + request: Request, + { + action, + resource, + allowJWT = false, + }: { action: string; resource: RbacResource; allowJWT?: boolean } +): 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 rbac.authenticateAuthorizeBearer( + request, + { action, resource }, + { allowJWT } + ); + if (!result.ok) { + return result; + } + + return { + ok: true, + authentication: { + ok: true, + apiKey, + type: "PRIVATE", + environment: result.environment, + ability: result.ability, + }, + }; +} + export async function authenticateAuthorizationHeader( authorization: string, { @@ -434,7 +502,10 @@ export async function authenticateRequest< } if (allowedMethods.apiKey) { - const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName }); + const result = await authenticateApiKey(apiKey, { + allowPublicKey: false, + branchName, + }); if (!result) { return; diff --git a/apps/webapp/app/services/authFeatureControls.server.ts b/apps/webapp/app/services/authFeatureControls.server.ts new file mode 100644 index 0000000000..5052b6b5a3 --- /dev/null +++ b/apps/webapp/app/services/authFeatureControls.server.ts @@ -0,0 +1,13 @@ +import { resolveAuthFeatureControls } from "~/services/authFeatureControls"; +import { globalFlagsRegistry } from "~/v3/globalFlagsRegistry.server"; + +export { resolveAuthFeatureControls } from "~/services/authFeatureControls"; +export type { AuthFeatureControls } from "~/services/authFeatureControls"; + +function currentControls() { + return resolveAuthFeatureControls(globalFlagsRegistry.current()); +} + +export const authFeatureControls = { + additionalApiKeyLookupEnabled: () => currentControls().additionalApiKeyLookupEnabled, +}; diff --git a/apps/webapp/app/services/authFeatureControls.ts b/apps/webapp/app/services/authFeatureControls.ts new file mode 100644 index 0000000000..1707dee37f --- /dev/null +++ b/apps/webapp/app/services/authFeatureControls.ts @@ -0,0 +1,13 @@ +import { FEATURE_FLAG, type FeatureFlagCatalog } from "~/v3/featureFlags"; + +export type AuthFeatureControls = { + additionalApiKeyLookupEnabled: boolean; +}; + +export function resolveAuthFeatureControls( + flags: Partial | Record | undefined +): AuthFeatureControls { + return { + additionalApiKeyLookupEnabled: flags?.[FEATURE_FLAG.additionalApiKeyLookupEnabled] === true, + }; +} diff --git a/apps/webapp/app/services/authTelemetry.server.ts b/apps/webapp/app/services/authTelemetry.server.ts new file mode 100644 index 0000000000..9a229c70f3 --- /dev/null +++ b/apps/webapp/app/services/authTelemetry.server.ts @@ -0,0 +1,148 @@ +import { getMeter } from "@internal/tracing"; +import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; +import { isPublicJWT } from "@trigger.dev/core/v3/jwt"; +import type { + BearerCredentialKind, + BearerLookupPath, + HostBearerAuthResult, +} from "@trigger.dev/rbac"; +import { authFeatureControls } from "~/services/authFeatureControls.server"; +import { rbac } from "~/services/rbac.server"; +import { singleton } from "~/utils/singleton"; + +export type ApiAuthResult = "success" | "invalid" | "forbidden" | "disabled" | "error"; + +const telemetry = singleton("apiAuthTelemetry", () => { + const meter = getMeter("api-auth"); + const attempts = meter.createCounter("api_auth.attempts", { + description: "Completed environment bearer authentication attempts", + }); + const duration = meter.createHistogram("api_auth.duration_ms", { + description: "Environment bearer authentication duration", + unit: "ms", + }); + + meter + .createObservableGauge("api_auth.rollout_mode", { + description: "Active API authentication rollout modes", + }) + .addCallback((result) => { + result.observe(1, { + control: "additional_key_lookup", + mode: authFeatureControls.additionalApiKeyLookupEnabled() ? "enabled" : "disabled", + }); + }); + + return { attempts, duration }; +}); + +export async function authenticateBearerWithTelemetry( + request: Request, + options: { allowJWT: boolean } +): Promise { + const startedAt = performance.now(); + const classified = classifyCredential(request, options.allowJWT); + let final = { ...classified, result: "error" as ApiAuthResult }; + + try { + const result = await rbac.authenticateBearer(request, options); + // The host LazyController always attaches `resolution`; fall back to the + // format-based classification if a caller (e.g. a test double) omits it. + const resolution = result.resolution ?? classified; + final = { + credentialKind: resolution.credentialKind, + lookupPath: resolution.lookupPath, + result: result.ok + ? "success" + : resolution.lookupPath === "additional_skipped" + ? "disabled" + : result.status === 403 + ? "forbidden" + : "invalid", + }; + recordAuthAttempt("rbac", final.credentialKind, final.lookupPath, final.result); + return result; + } catch (error) { + recordAuthAttempt("rbac", final.credentialKind, final.lookupPath, final.result); + throw error; + } finally { + telemetry.duration.record(performance.now() - startedAt, { + resolver: "rbac", + credential_kind: final.credentialKind, + result: final.result, + lookup_path: final.lookupPath, + }); + } +} + +export async function observeLegacyBearerAuthentication( + request: Request, + operation: () => Promise +): Promise { + const startedAt = performance.now(); + const classified = classifyCredential(request, true); + const lookupPath: BearerLookupPath = + classified.credentialKind === "additional_api_key" && + !authFeatureControls.additionalApiKeyLookupEnabled() + ? "additional_skipped" + : classified.lookupPath; + let result: ApiAuthResult = "error"; + + try { + const value = await operation(); + result = value?.ok ? "success" : lookupPath === "additional_skipped" ? "disabled" : "invalid"; + recordAuthAttempt("legacy", classified.credentialKind, lookupPath, result); + return value; + } catch (error) { + recordAuthAttempt("legacy", classified.credentialKind, lookupPath, result); + throw error; + } finally { + telemetry.duration.record(performance.now() - startedAt, { + resolver: "legacy", + credential_kind: classified.credentialKind, + result, + lookup_path: lookupPath, + }); + } +} + +function recordAuthAttempt( + resolver: "rbac" | "legacy", + credentialKind: BearerCredentialKind, + lookupPath: BearerLookupPath, + result: ApiAuthResult +) { + telemetry.attempts.add(1, { + resolver, + credential_kind: credentialKind, + result, + lookup_path: lookupPath, + }); +} + +// Best-effort pre-classification from the raw token format. This is only used +// for the metric attributes when the resolver throws before returning a +// resolution; the resolver's own resolution is authoritative on success/failure. +// Never records the credential itself — only its bounded format class. +function classifyCredential( + request: Request, + allowJWT: boolean +): { credentialKind: BearerCredentialKind; lookupPath: BearerLookupPath } { + const token = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + if (!token) return { credentialKind: "unknown", lookupPath: "not_found" }; + if (token.startsWith("pk_")) { + return { credentialKind: "legacy_public_key", lookupPath: "legacy_public" }; + } + if (allowJWT && isPublicJWT(token)) { + return { credentialKind: "public_jwt", lookupPath: "jwt_current" }; + } + if (isAdditionalApiKey(token)) { + return { credentialKind: "additional_api_key", lookupPath: "additional" }; + } + return token.startsWith("tr_") + ? { credentialKind: "root_api_key", lookupPath: "root_current" } + : { credentialKind: "unknown", lookupPath: "not_found" }; +} diff --git a/apps/webapp/app/services/environmentVariableApiAccess.server.ts b/apps/webapp/app/services/environmentVariableApiAccess.server.ts index 11d401125a..e9fe2ef781 100644 --- a/apps/webapp/app/services/environmentVariableApiAccess.server.ts +++ b/apps/webapp/app/services/environmentVariableApiAccess.server.ts @@ -1,10 +1,73 @@ import { json } from "@remix-run/server-runtime"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { isUserActorToken } from "@trigger.dev/rbac"; +import type { RbacAbility } from "@trigger.dev/rbac"; +import { + authenticateApiKeyWithScope, + authenticateRequest, + type AuthenticationResult, +} from "~/services/apiAuth.server"; import { rbac } from "~/services/rbac.server"; type EnvironmentScopedResource = "envvars" | "apiKeys"; +type EnvironmentScopedAuthentication = + | { ok: true; authentication: AuthenticationResult } + | { ok: false; status: 401 | 403; error: string }; + +/** + * Returns the credential already presented by an authenticated API-key caller. + * API-key exchanges must reuse this value instead of exposing the environment's + * root credential. + */ +export function presentedApiKeyFromAuthentication( + authentication: AuthenticationResult +): string | undefined { + return authentication.type === "apiKey" && authentication.result.ok + ? authentication.result.apiKey + : undefined; +} + +/** + * Keep PAT/OAT authentication on the legacy path while routing machine API + * keys through the RBAC controller, where plugin grants are applied. + */ +export async function authenticateEnvironmentScopedApiRequest( + request: Request, + action: "read" | "write", + resource: EnvironmentScopedResource +): Promise { + const userOrOrganizationAuthentication = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); + if (userOrOrganizationAuthentication) { + return { ok: true, authentication: userOrOrganizationAuthentication }; + } + + const apiKeyAuthentication = await authenticateApiKeyWithScope(request, { + action, + resource: { type: resource }, + }); + if (!apiKeyAuthentication.ok) { + return apiKeyAuthentication; + } + + return { + ok: true, + authentication: { type: "apiKey", result: apiKeyAuthentication.authentication }, + }; +} + +/** Env var API routes: PAT/OAT on the legacy path, machine keys via RBAC. */ +export function authenticateEnvVarApiRequest( + request: Request, + action: "read" | "write" +): Promise { + return authenticateEnvironmentScopedApiRequest(request, action, "envvars"); +} + const RESOURCE_LABELS: Record = { envvars: "environment variables", apiKeys: "API keys", @@ -14,8 +77,8 @@ const RESOURCE_LABELS: Record = { * Env-tier RBAC for environment-scoped API routes (env vars, and the endpoints * that hand out an environment's secret credentials). * - * Machine credentials (an environment's secret/public API key) are already - * scoped to a single environment, so they pass through unchanged. A personal + * Machine credentials (an environment's API key) are authorized by the + * ability returned by the RBAC bearer controller. A personal * access token (or a delegated user-actor token) carries a user, so enforce * that user's role for the targeted environment tier — e.g. a Developer can't * read deployed env vars or API keys via the API, matching the dashboard @@ -34,6 +97,7 @@ export async function authorizePatEnvironmentAccess({ envType, resource, action, + ability, }: { request: Request; authType: "personalAccessToken" | "organizationAccessToken" | "apiKey"; @@ -42,6 +106,8 @@ export async function authorizePatEnvironmentAccess({ envType: RuntimeEnvironmentType; resource: EnvironmentScopedResource; action: "read" | "write"; + // Controller ability for API-key credentials. Absent for PAT/OAT callers. + ability?: RbacAbility; }): Promise { const bearer = request.headers .get("Authorization") @@ -49,8 +115,22 @@ export async function authorizePatEnvironmentAccess({ .trim(); const isUat = !!bearer && isUserActorToken(bearer); - // Machine creds (apiKey) and org tokens carry no user role to enforce. A - // user-actor token carries a user just like a PAT, so it's gated too. + // Machine API keys are authorized by their controller ability. Root keys and + // ungranted additional keys are permissive; granted keys are restricted. + if (authType === "apiKey") { + if (ability?.can(action, { type: resource })) { + return undefined; + } + return json( + { + error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`, + }, + { status: 403 } + ); + } + + // Org tokens carry no user role to enforce. A user-actor token carries a + // user just like a PAT, so it's gated too. if (authType !== "personalAccessToken" && !isUat) { return undefined; } @@ -82,6 +162,7 @@ export function authorizeEnvVarApiRequest(opts: { projectId: string; envType: RuntimeEnvironmentType; action: "read" | "write"; + ability?: RbacAbility; }): Promise { return authorizePatEnvironmentAccess({ ...opts, resource: "envvars" }); } diff --git a/apps/webapp/app/services/publicAccessTokenResponse.server.ts b/apps/webapp/app/services/publicAccessTokenResponse.server.ts new file mode 100644 index 0000000000..f4f20bcf7d --- /dev/null +++ b/apps/webapp/app/services/publicAccessTokenResponse.server.ts @@ -0,0 +1,33 @@ +import { generateJWT } from "@trigger.dev/core/v3"; +import { resolveJwtSigningKey } from "@trigger.dev/rbac"; + +export type PublicAccessTokenEnvironment = { + id: string; + apiKey: string; + parentEnvironment?: { apiKey: string } | null; +}; + +export async function publicAccessTokenResponseHeaders({ + environment, + scopes, + expirationTime, +}: { + environment: PublicAccessTokenEnvironment; + scopes: string[]; + expirationTime: string; +}): Promise> { + const jwt = await generateJWT({ + secretKey: resolveJwtSigningKey(environment), + payload: { + sub: environment.id, + pub: true, + scopes, + }, + expirationTime, + }); + + return { + "x-trigger-jwt-claims": JSON.stringify({ sub: environment.id, pub: true }), + "x-trigger-jwt": jwt, + }; +} diff --git a/apps/webapp/app/services/rbac.server.ts b/apps/webapp/app/services/rbac.server.ts index 11510c1358..fc1cfd58cf 100644 --- a/apps/webapp/app/services/rbac.server.ts +++ b/apps/webapp/app/services/rbac.server.ts @@ -2,6 +2,7 @@ import { $replica, prisma } from "~/db.server"; import type { PrismaClient } from "@trigger.dev/database"; import plugin from "@trigger.dev/rbac"; import { env } from "~/env.server"; +import { authFeatureControls } from "~/services/authFeatureControls.server"; // plugin.create() is synchronous — returns a lazy controller that resolves // any installed RBAC plugin on first call. Top-level await is not used @@ -30,6 +31,7 @@ export const rbac = plugin.create( { forceFallback: env.RBAC_FORCE_FALLBACK, userActorSecret: env.SESSION_SECRET, + additionalApiKeyLookupEnabled: authFeatureControls.additionalApiKeyLookupEnabled, // A plugin that owns its own database client gets the same // writer/replica topology the webapp's Prisma clients use (see // getClient/getReplicaClient in db.server.ts): control-plane URLs win, diff --git a/apps/webapp/app/services/realtime/jwtAuth.server.ts b/apps/webapp/app/services/realtime/jwtAuth.server.ts index ed99121430..2806a73701 100644 --- a/apps/webapp/app/services/realtime/jwtAuth.server.ts +++ b/apps/webapp/app/services/realtime/jwtAuth.server.ts @@ -1,4 +1,10 @@ -import { validateJWT, type ValidationResult } from "@trigger.dev/core/v3/jwt"; +import { + extractJWTSub, + isPublicJWT, + validateJWT, + type ValidationResult, +} from "@trigger.dev/core/v3/jwt"; +import { resolveJwtSigningKey } from "@trigger.dev/rbac"; import { $replica } from "~/db.server"; import { findEnvironmentById } from "~/models/runtimeEnvironment.server"; import type { AuthenticatedEnvironment } from "../apiAuth.server"; @@ -33,10 +39,10 @@ export async function validatePublicJwtKey(token: string): Promise { - const result = await rbac.authenticateBearer(request, { allowJWT }); + const result = await authenticateBearerWithTelemetry(request, { allowJWT }); if (!result.ok) { // Plugin auth distinguishes 401 (who are you?) from 403 (you're not // allowed) — e.g. a suspended account or IP block returns 403. @@ -78,7 +84,47 @@ async function authenticateRequestForApiBuilder( actor: result.jwt?.act, }; - return { ok: true, authentication, ability: result.ability }; + return { + ok: true, + authentication, + ability: result.ability, + restrictedApiKey: result.subject.type === "apiKey" && result.subject.restricted, + }; +} + +export function shouldRejectRestrictedKeyWithoutAuthorization( + restrictedApiKey: boolean, + hasAuthorization: boolean +): boolean { + return restrictedApiKey && !hasAuthorization; +} + +async function rejectRestrictedKeyWithoutAuthorization({ + request, + restrictedApiKey, + hasAuthorization, + useCors, +}: { + request: Request; + restrictedApiKey: boolean; + hasAuthorization: boolean; + useCors: boolean; +}): Promise { + if (!shouldRejectRestrictedKeyWithoutAuthorization(restrictedApiKey, hasAuthorization)) return; + + return wrapResponse( + request, + json( + { + error: "Unauthorized", + code: "unauthorized", + param: "access_token", + type: "authorization", + }, + { status: 403 } + ), + useCors + ); } type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; @@ -115,14 +161,18 @@ type AnyResourceAuth = { type EveryResourceAuth = { readonly [EVERY_RESOURCE_MARKER]: true; readonly resources: readonly RbacResource[]; + readonly orResources: readonly RbacResource[]; }; export function anyResource(resources: RbacResource[]): AnyResourceAuth { return { [ANY_RESOURCE_MARKER]: true, resources }; } -export function everyResource(resources: RbacResource[]): EveryResourceAuth { - return { [EVERY_RESOURCE_MARKER]: true, resources }; +export function everyResource( + resources: RbacResource[], + orResources: RbacResource[] = [] +): EveryResourceAuth { + return { [EVERY_RESOURCE_MARKER]: true, resources, orResources }; } function isAnyResource(value: unknown): value is AnyResourceAuth { @@ -143,8 +193,15 @@ function isEveryResource(value: unknown): value is EveryResourceAuth { type AuthResource = RbacResource | AnyResourceAuth | EveryResourceAuth; -function checkAuth(ability: RbacAbility, action: string, resource: AuthResource): boolean { +export function checkAuth(ability: RbacAbility, action: string, resource: AuthResource): boolean { if (isEveryResource(resource)) { + // A broad collection grant may be supplied as an alternative to all + // instance checks. This preserves scopes such as read:runs while making + // mixed selected-task filters require access to every requested task. + if (resource.orResources.length > 0 && ability.can(action, [...resource.orResources])) { + return true; + } + // Empty array via [].every() is vacuously true — would let any token // pass auth. Routes building everyResource() from request bodies // (e.g. batch trigger items) should never produce zero elements @@ -223,6 +280,7 @@ type ApiKeyHandlerFunction< ? z.infer : undefined; authentication: ApiAuthenticationResultSuccess; + ability: RbacAbility; request: Request; resource: NonNullable; apiVersion: API_VERSIONS; @@ -263,6 +321,13 @@ export function createLoaderApiRoute< ); } const { authentication: authenticationResult, ability } = authResult; + const restrictedKeyRejection = await rejectRestrictedKeyWithoutAuthorization({ + request, + restrictedApiKey: authResult.restrictedApiKey, + hasAuthorization: authorization !== undefined, + useCors: corsStrategy !== "none", + }); + if (restrictedKeyRejection) return restrictedKeyRejection; let parsedParams: any = undefined; if (paramsSchema) { @@ -364,6 +429,7 @@ export function createLoaderApiRoute< searchParams: parsedSearchParams, headers: parsedHeaders, authentication: authenticationResult, + ability, request, resource, apiVersion, @@ -986,6 +1052,7 @@ type ApiKeyActionHandlerFunction< ? z.infer : undefined; authentication: ApiAuthenticationResultSuccess; + ability: RbacAbility; request: Request; resource?: TResource; }) => Promise; @@ -1055,6 +1122,13 @@ export function createActionApiRoute< ); } const { authentication: authenticationResult, ability } = authResult; + const restrictedKeyRejection = await rejectRestrictedKeyWithoutAuthorization({ + request, + restrictedApiKey: authResult.restrictedApiKey, + hasAuthorization: authorization !== undefined, + useCors: corsStrategy !== "none", + }); + if (restrictedKeyRejection) return restrictedKeyRejection; if (maxContentLength) { const contentLength = request.headers.get("content-length"); @@ -1212,6 +1286,7 @@ export function createActionApiRoute< headers: parsedHeaders, body: parsedBody, authentication: authenticationResult, + ability, request, resource, }) @@ -1340,6 +1415,13 @@ export function createMultiMethodApiRoute< ); } const { authentication: authenticationResult, ability } = authResult; + const restrictedKeyRejection = await rejectRestrictedKeyWithoutAuthorization({ + request, + restrictedApiKey: authResult.restrictedApiKey, + hasAuthorization: authorization !== undefined, + useCors: corsStrategy !== "none", + }); + if (restrictedKeyRejection) return restrictedKeyRejection; if (maxContentLength) { const contentLength = request.headers.get("content-length"); diff --git a/apps/webapp/app/utils/batchItemAuthorization.ts b/apps/webapp/app/utils/batchItemAuthorization.ts new file mode 100644 index 0000000000..45b1988877 --- /dev/null +++ b/apps/webapp/app/utils/batchItemAuthorization.ts @@ -0,0 +1,96 @@ +import type { RbacAbility } from "@trigger.dev/rbac"; + +export class BatchItemAuthorizationError extends Error {} + +export function batchPublicAccessScopes( + batchId: string, + ability: RbacAbility, + browserClient: boolean +): string[] { + const scopes = [`read:batch:${batchId}`]; + + if (browserClient && ability.can("batchTrigger", { type: "tasks" })) { + scopes.push(`write:batch:${batchId}`); + } + + return scopes; +} + +/** + * Wraps `authorizeBatchItems` so a credential without a batch-level write grant + * must present at least one authorized item before the batch is touched at all. + * + * A selected-task credential can only be authorized by the items it streams — + * by design, see the phase-two comment in `api.v3.batches.ts`. So a stream that + * yields nothing passes zero checks, and because `StreamBatchItemsService` does + * its batch lookup *before* it ever pulls an item, its "not found" / "not in + * PENDING (current: …)" / `runCount` responses would leak an arbitrary batch's + * existence, lifecycle state and progress to any key in the environment. This + * route bypasses the route builder's `restrictedApiKey && !authorization -> 403` + * fail-closed, so nothing else stops that probe. + * + * Pulling and authorizing the first item up front closes it: no item, no + * authorization. The auth layer must never grant on no input — the same rule + * `checkAuth` applies for empty resource arrays in `apiBuilder.server.ts`. + */ +export async function authorizedBatchItemStream( + items: AsyncIterable, + ability: RbacAbility, + batchId: string +): Promise> { + const authorized = authorizeBatchItems(items, ability, batchId); + + // A batch-level write grant is authorization in its own right, so an empty + // stream stays legal for credentials that own the batch: root keys (via the + // permissive ability) and phase one's delegated browser token. Only a + // restricted credential, which has nothing but its items to go on, is held + // to the at-least-one-item rule. + if (ability.can("write", { type: "batch", id: batchId })) { + return authorized; + } + + const iterator = authorized[Symbol.asyncIterator](); + const first = await iterator.next(); + + if (first.done) { + throw new BatchItemAuthorizationError(); + } + + return (async function* () { + try { + yield first.value; + + while (true) { + const next = await iterator.next(); + if (next.done) return; + yield next.value; + } + } finally { + // Consumers can stop early — p-map abandons the iterator when a mapper + // rejects. Close the generator we pulled from so it unwinds its own + // `for await` and releases the request body stream. + await iterator.return?.(); + } + })(); +} + +export async function* authorizeBatchItems( + items: AsyncIterable, + ability: RbacAbility, + batchId: string +): AsyncIterable { + const canWriteBatch = ability.can("write", { type: "batch", id: batchId }); + + for await (const item of items) { + const task = + typeof item === "object" && item !== null && "task" in item && typeof item.task === "string" + ? item.task + : undefined; + + if (!canWriteBatch && (!task || !ability.can("batchTrigger", { type: "tasks", id: task }))) { + throw new BatchItemAuthorizationError(); + } + + yield item; + } +} diff --git a/apps/webapp/app/utils/parentRunAuthorization.server.ts b/apps/webapp/app/utils/parentRunAuthorization.server.ts new file mode 100644 index 0000000000..fac9b96dd2 --- /dev/null +++ b/apps/webapp/app/utils/parentRunAuthorization.server.ts @@ -0,0 +1,27 @@ +import type { RbacAbility } from "@trigger.dev/rbac"; +import { resolveRunForMutation } from "~/v3/mollifier/resolveRunForMutation.server"; +import { canWriteResolvedParentRun } from "./parentRunAuthorization"; + +export async function canWriteParentRun( + ability: RbacAbility, + environmentId: string, + organizationId: string, + parentRunId: string | null | undefined +): Promise { + if (!parentRunId) return true; + + // A type-level `write:runs` grant covers every run in the environment, so + // resolving the parent could only confirm what we already know. Root keys and + // any other unrestricted credential take this branch, which keeps the trigger + // hot path free of the extra lookup. + if (ability.can("write", { type: "runs" })) return true; + + const run = await resolveRunForMutation({ + environmentId, + organizationId, + runParam: parentRunId, + }); + if (!run) return false; + + return canWriteResolvedParentRun(ability, run); +} diff --git a/apps/webapp/app/utils/parentRunAuthorization.ts b/apps/webapp/app/utils/parentRunAuthorization.ts new file mode 100644 index 0000000000..fc245ed242 --- /dev/null +++ b/apps/webapp/app/utils/parentRunAuthorization.ts @@ -0,0 +1,13 @@ +import type { RbacAbility } from "@trigger.dev/rbac"; + +type ParentRunResource = { + friendlyId: string; + taskIdentifier: string; +}; + +export function canWriteResolvedParentRun(ability: RbacAbility, run: ParentRunResource): boolean { + return ability.can("write", [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ]); +} diff --git a/apps/webapp/app/utils/requestIdempotency.test.ts b/apps/webapp/app/utils/requestIdempotency.test.ts new file mode 100644 index 0000000000..a422646c63 --- /dev/null +++ b/apps/webapp/app/utils/requestIdempotency.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { scopeRequestIdempotencyKey } from "./requestIdempotencyKey"; + +describe("scopeRequestIdempotencyKey", () => { + it("keeps retries stable within the same environment and task scope", () => { + expect(scopeRequestIdempotencyKey("request-1", ["env-1", "task-a"])).toBe( + scopeRequestIdempotencyKey("request-1", ["env-1", "task-a"]) + ); + }); + + it("does not share cache entries across environments or tasks", () => { + const original = scopeRequestIdempotencyKey("request-1", ["env-1", "task-a"]); + + expect(scopeRequestIdempotencyKey("request-1", ["env-1", "task-b"])).not.toBe(original); + expect(scopeRequestIdempotencyKey("request-1", ["env-2", "task-a"])).not.toBe(original); + }); + + it("preserves missing request keys", () => { + expect(scopeRequestIdempotencyKey(undefined, ["env-1", "task-a"])).toBeUndefined(); + expect(scopeRequestIdempotencyKey(null, ["env-1", "task-a"])).toBeNull(); + }); +}); diff --git a/apps/webapp/app/utils/requestIdempotencyKey.ts b/apps/webapp/app/utils/requestIdempotencyKey.ts new file mode 100644 index 0000000000..90594f1a0b --- /dev/null +++ b/apps/webapp/app/utils/requestIdempotencyKey.ts @@ -0,0 +1,12 @@ +import { createHash } from "node:crypto"; + +export function scopeRequestIdempotencyKey( + requestIdempotencyKey: string | null | undefined, + scope: readonly string[] +): string | null | undefined { + if (!requestIdempotencyKey) return requestIdempotencyKey; + + return createHash("sha256") + .update(JSON.stringify([requestIdempotencyKey, ...scope])) + .digest("hex"); +} diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 49a18eaf35..e90f25f7ea 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -22,6 +22,9 @@ export const FEATURE_FLAG = { // Grace-linger stamp carried alongside runOpsMintKind on flip. See mintFlipGrace.ts. runOpsMintKindPrev: "runOpsMintKindPrev", runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt", + // System-wide kill switch for additional (scoped) environment API-key lookup. + // Defaults off; enable during rollout once the new lookup path is trusted. + additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled", } as const; export const FeatureFlagCatalog = { @@ -61,6 +64,10 @@ export const FeatureFlagCatalog = { // by stampMintKindFlip on a genuine flip. Display-only (see ORG_LOCKED_FLAGS). [FEATURE_FLAG.runOpsMintKindPrev]: z.enum(["cuid", "runOpsId"]), [FEATURE_FLAG.runOpsMintKindFlippedAt]: z.string().datetime(), + // Strict z.boolean() (not z.coerce.boolean()): coercion turns the string + // "false" into true, which would silently enable this kill switch the wrong + // way if written as a string. Cold/absent resolves to the safe `false`. + [FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(), }; export type FeatureFlagKey = keyof typeof FeatureFlagCatalog; @@ -79,6 +86,8 @@ export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [ FEATURE_FLAG.taskEventRepository, FEATURE_FLAG.runOpsMintKindPrev, FEATURE_FLAG.runOpsMintKindFlippedAt, + // System-wide only — an org must not be able to override the rollout switch. + FEATURE_FLAG.additionalApiKeyLookupEnabled, ]; // Create a Zod schema from the existing catalog diff --git a/apps/webapp/app/v3/mollifier/resolveRunForMutation.server.ts b/apps/webapp/app/v3/mollifier/resolveRunForMutation.server.ts index 258c0da211..4679ef91b7 100644 --- a/apps/webapp/app/v3/mollifier/resolveRunForMutation.server.ts +++ b/apps/webapp/app/v3/mollifier/resolveRunForMutation.server.ts @@ -15,8 +15,8 @@ import { getMollifierBuffer as defaultGetBuffer } from "./mollifierBuffer.server // `findResource: async () => null`, which made every cancel 404 before // the action ran. The helper makes the lookup unit-testable.) export type ResolvedRunForMutation = - | { source: "pg"; friendlyId: string } - | { source: "buffer"; friendlyId: string }; + | { source: "pg"; friendlyId: string; taskIdentifier: string } + | { source: "buffer"; friendlyId: string; taskIdentifier: string }; export type ResolveRunForMutationDeps = { prismaReplica?: PrismaReplicaClient; @@ -36,17 +36,34 @@ export async function resolveRunForMutation(input: { const pgRun = await runStore.findRun( { friendlyId: input.runParam, runtimeEnvironmentId: input.environmentId }, - { select: { friendlyId: true } }, + { select: { friendlyId: true, taskIdentifier: true } }, replica ); - if (pgRun) return { source: "pg", friendlyId: pgRun.friendlyId }; + if (pgRun) { + return { + source: "pg", + friendlyId: pgRun.friendlyId, + taskIdentifier: pgRun.taskIdentifier, + }; + } const buffer = getBuffer(); if (buffer) { const entry = await buffer.getEntry(input.runParam); if (entry && entry.envId === input.environmentId && entry.orgId === input.organizationId) { - return { source: "buffer", friendlyId: input.runParam }; + try { + const snapshot = JSON.parse(entry.payload) as { taskIdentifier?: unknown }; + if (typeof snapshot.taskIdentifier === "string") { + return { + source: "buffer", + friendlyId: input.runParam, + taskIdentifier: snapshot.taskIdentifier, + }; + } + } catch { + // A malformed snapshot is not an authorizable run resource. + } } } @@ -64,10 +81,16 @@ export async function resolveRunForMutation(input: { // downstream mutateWithFallback flow would otherwise handle correctly. const writerRun = await runStore.findRun( { friendlyId: input.runParam, runtimeEnvironmentId: input.environmentId }, - { select: { friendlyId: true } }, + { select: { friendlyId: true, taskIdentifier: true } }, writer ); - if (writerRun) return { source: "pg", friendlyId: writerRun.friendlyId }; + if (writerRun) { + return { + source: "pg", + friendlyId: writerRun.friendlyId, + taskIdentifier: writerRun.taskIdentifier, + }; + } return null; } diff --git a/apps/webapp/test/apiAuthScope.test.ts b/apps/webapp/test/apiAuthScope.test.ts new file mode 100644 index 0000000000..5ddf7ffe5a --- /dev/null +++ b/apps/webapp/test/apiAuthScope.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rbacMocks = vi.hoisted(() => ({ + authenticateAuthorizeBearer: vi.fn<(...args: any[]) => Promise>(), +})); + +vi.mock("~/services/rbac.server", () => ({ rbac: rbacMocks })); +vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); +vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } })); +vi.mock("~/models/project.server", () => ({ findProjectByRef: vi.fn() })); +vi.mock("~/models/runtimeEnvironment.server", () => ({ + authIncludeBase: {}, + authIncludeWithParent: {}, + findEnvironmentByApiKey: vi.fn(), + findEnvironmentByPublicApiKey: vi.fn(), + toAuthenticated: vi.fn(), +})); +vi.mock("~/services/personalAccessToken.server", () => ({ + authenticateApiRequestWithPersonalAccessToken: vi.fn(), + isPersonalAccessToken: () => false, +})); +vi.mock("~/services/organizationAccessToken.server", () => ({ + authenticateApiRequestWithOrganizationAccessToken: vi.fn(), + isOrganizationAccessToken: () => false, +})); +vi.mock("~/services/realtime/jwtAuth.server", () => ({ + isPublicJWT: () => false, + validatePublicJwtKey: vi.fn(), +})); +vi.mock("~/services/logger.server", () => ({ + logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; + +describe("authenticateApiKeyWithScope", () => { + beforeEach(() => { + rbacMocks.authenticateAuthorizeBearer.mockReset(); + }); + + it("returns 401 without a bearer credential", async () => { + const result = await authenticateApiKeyWithScope(new Request("https://example.com"), { + action: "read", + resource: { type: "envvars" }, + }); + + expect(result).toEqual({ + ok: false, + status: 401, + error: "Invalid or Missing API key", + }); + expect(rbacMocks.authenticateAuthorizeBearer).not.toHaveBeenCalled(); + }); + + it.each([ + { status: 401 as const, error: "Invalid API key" }, + { status: 403 as const, error: "Unauthorized" }, + ])("preserves controller $status failures", async (failure) => { + rbacMocks.authenticateAuthorizeBearer.mockResolvedValue({ ok: false, ...failure }); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer tr_test_key" }, + }); + + await expect( + authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }) + ).resolves.toEqual({ ok: false, ...failure }); + }); + + it("bridges controller success into the legacy private authentication shape", async () => { + const environment = { id: "env_123" }; + const ability = { can: vi.fn(() => true), canSuper: vi.fn(() => true) }; + rbacMocks.authenticateAuthorizeBearer.mockResolvedValue({ + ok: true, + environment, + ability, + subject: { type: "apiKey", apiKeyId: "key_123" }, + }); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer tr_test_key", "x-trigger-branch": "feature/test" }, + }); + + const result = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "envvars" }, + allowJWT: true, + }); + + expect(rbacMocks.authenticateAuthorizeBearer).toHaveBeenCalledWith( + request, + { action: "read", resource: { type: "envvars" } }, + { allowJWT: true } + ); + expect(result).toEqual({ + ok: true, + authentication: { + ok: true, + apiKey: "tr_test_key", + type: "PRIVATE", + environment, + ability, + }, + }); + }); +}); diff --git a/apps/webapp/test/apiBuilderAuthorization.test.ts b/apps/webapp/test/apiBuilderAuthorization.test.ts new file mode 100644 index 0000000000..be108bf279 --- /dev/null +++ b/apps/webapp/test/apiBuilderAuthorization.test.ts @@ -0,0 +1,71 @@ +import { buildJwtAbility } from "@trigger.dev/plugins"; +import { describe, expect, it } from "vitest"; +import { + checkAuth, + everyResource, + shouldRejectRestrictedKeyWithoutAuthorization, +} from "~/services/routeBuilders/apiBuilder.server"; + +describe("restricted API key route authorization", () => { + it("fails closed when a route has no authorization declaration", () => { + expect(shouldRejectRestrictedKeyWithoutAuthorization(true, false)).toBe(true); + expect(shouldRejectRestrictedKeyWithoutAuthorization(true, true)).toBe(false); + expect(shouldRejectRestrictedKeyWithoutAuthorization(false, false)).toBe(false); + }); +}); + +describe("everyResource authorization", () => { + it("requires an ID-scoped ability to match every requested resource", () => { + const ability = buildJwtAbility(["read:tasks:task-a"]); + + expect( + checkAuth( + ability, + "read", + everyResource( + [ + { type: "tasks", id: "task-a" }, + { type: "tasks", id: "task-b" }, + ], + [{ type: "runs" }, { type: "tasks" }] + ) + ) + ).toBe(false); + }); + + it("allows an ID-scoped ability when every requested resource matches", () => { + const ability = buildJwtAbility(["read:tasks:task-a", "read:tasks:task-b"]); + + expect( + checkAuth( + ability, + "read", + everyResource( + [ + { type: "tasks", id: "task-a" }, + { type: "tasks", id: "task-b" }, + ], + [{ type: "runs" }, { type: "tasks" }] + ) + ) + ).toBe(true); + }); + + it("preserves broad collection grants as an alternative", () => { + const ability = buildJwtAbility(["read:runs"]); + + expect( + checkAuth( + ability, + "read", + everyResource( + [ + { type: "tasks", id: "task-a" }, + { type: "tasks", id: "task-b" }, + ], + [{ type: "runs" }, { type: "tasks" }] + ) + ) + ).toBe(true); + }); +}); diff --git a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts index a46cd0894f..c393526b9c 100644 --- a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts +++ b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts @@ -2,6 +2,7 @@ import { postgresTest, heteroPostgresTest } from "@internal/testcontainers"; import { PostgresRunStore } from "@internal/run-store"; import type { Prisma, PrismaClient } from "@trigger.dev/database"; import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic"; +import type { RbacAbility } from "@trigger.dev/rbac"; import { beforeEach, describe, expect, vi } from "vitest"; // `resolveSchedule` reads the module-level `prisma` (control-plane handle). @@ -304,6 +305,7 @@ async function seedRunWithTree( runFriendlyId: `run_${runId}`, parentFriendlyId: `run_${parentId}`, rootFriendlyId: `run_${rootId}`, + childId, childFriendlyId: `run_${childId}`, attemptId: attempt.id, }; @@ -354,6 +356,10 @@ describe("ApiRetrieveRunPresenter.findRun store-routed read (single-DB invariant where: { id: tree.run.id }, data: { scheduleId }, }); + await prisma.taskRun.update({ + where: { id: tree.childId }, + data: { taskIdentifier: "other-task" }, + }); const env = authEnv(organization, project, runtimeEnvironment); const found = await readFoundRunViaStore(store, tree.runFriendlyId, env.id); @@ -378,6 +384,28 @@ describe("ApiRetrieveRunPresenter.findRun store-routed read (single-DB invariant expect(out.relatedRuns.root?.id).toBe(tree.rootFriendlyId); expect(out.relatedRuns.children.map((c) => c.id)).toEqual([tree.childFriendlyId]); expect(out.attemptCount).toBe(found!.attemptNumber ?? 0); + + const selectedTaskAbility: RbacAbility = { + can: (action, resource) => { + const resources = Array.isArray(resource) ? resource : [resource]; + return ( + action === "read" && + resources.some( + (candidate) => candidate.type === "tasks" && candidate.id === found!.taskIdentifier + ) + ); + }, + canSuper: () => false, + }; + const scopedOut = await new ApiRetrieveRunPresenter(CURRENT_API_VERSION).call( + found!, + env, + selectedTaskAbility + ); + + expect(scopedOut.relatedRuns.parent?.id).toBe(tree.parentFriendlyId); + expect(scopedOut.relatedRuns.root?.id).toBe(tree.rootFriendlyId); + expect(scopedOut.relatedRuns.children).toEqual([]); } ); diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts index 988a8f7071..159eb02646 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -744,13 +744,16 @@ describe("API", () => { }); }); - // v3 batches use a collection-level resource { type: "tasks" } with - // no id — items are validated per-row when streamed. So id-specific - // scopes (write:tasks:foo) shouldn't grant blanket access; only - // type-level write:tasks (or admin/write:all) should. - describe("Trigger task — batch v3 (api.v3.batches) collection-level", () => { + // v3 batch creation accepts an optional declaration of the distinct task + // identifiers that will be streamed. New clients send it so selected-task + // credentials can authorize the batch before its shell is created. Older + // clients omit it and retain the collection-level, fail-closed behavior. + describe("Trigger task — batch v3 (api.v3.batches)", () => { const path = "/api/v3/batches"; - const buildBody = () => ({ runCount: 1 }); + const buildBody = (taskIdentifiers?: string[]) => ({ + runCount: taskIdentifiers?.length ?? 1, + taskIdentifiers, + }); it("missing auth: 401", async () => { const server = getTestServer(); @@ -779,6 +782,67 @@ describe("API", () => { expect(res.status).not.toBe(403); }); + it("JWT with batchTrigger:tasks:taskA + declared taskA: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["batchTrigger:tasks:taskA"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with batchTrigger:tasks:taskA + a mixed declaration: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["batchTrigger:tasks:taskA"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA", "taskB"])), + }); + expect(res.status).toBe(403); + }); + + it("JWT with batchTrigger:tasks:taskA + no declaration: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["batchTrigger:tasks:taskA"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody()), + }); + expect(res.status).toBe(403); + }); + it("JWT with read:tasks: 403", async () => { const server = getTestServer(); const seed = await seedTestEnvironment(server.prisma); @@ -913,7 +977,7 @@ describe("API", () => { expect(res.status).toBe(403); }); - it("filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a → passes (array match)", async () => { + it("filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a → 403 (requires every task)", async () => { const server = getTestServer(); const seed = await seedTestEnvironment(server.prisma); const jwt = await generateJWT({ @@ -928,11 +992,9 @@ describe("API", () => { const res = await get("?filter%5BtaskIdentifier%5D=task_a%2Ctask_b", { Authorization: `Bearer ${jwt}`, }); - // Resource array is [{type:"runs"}, {type:"tasks",id:"task_a"}, {type:"tasks",id:"task_b"}]. - // The scope read:tasks:task_a matches the second element → access granted. - // Handler may 500 (ClickHouse unreachable in tests) but auth passed. - expect(res.status).not.toBe(401); - expect(res.status).not.toBe(403); + // A task-scoped JWT must authorize every requested task so including an + // unauthorized task in a multi-task filter cannot expose its runs. + expect(res.status).toBe(403); }); it("filter[taskIdentifier]=task_a + JWT read:tasks:task_z → 403 (no array match)", async () => { @@ -2473,13 +2535,14 @@ describe("API", () => { expect(res.status).not.toBe(403); }); - it("read:tasks (type-only) on no-filter list: 403 (filter is sessions, not tasks)", async () => { - // No filter → resource is `{ type: "sessions" }` only. read:tasks - // doesn't match the sessions type, so 403 — explicit narrowing. + it("read:tasks (type-only) on no-filter list: auth passes", async () => { + // Preserve the legacy behavior where a type-level task scope grants + // access to an unfiltered list while task ID scopes require a filter. const seed = await seedTestEnvironment(getTestServer().prisma); const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:tasks"]); const res = await fetchWithJwt(jwt); - expect(res.status).toBe(403); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); }); it("write:tasks:foo (wrong action) on filter=foo: 403", async () => { diff --git a/apps/webapp/test/authFeatureControls.test.ts b/apps/webapp/test/authFeatureControls.test.ts new file mode 100644 index 0000000000..5de10e348a --- /dev/null +++ b/apps/webapp/test/authFeatureControls.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { resolveAuthFeatureControls } from "~/services/authFeatureControls"; +import { FEATURE_FLAG, FeatureFlagCatalog, ORG_LOCKED_FLAGS } from "~/v3/featureFlags"; + +describe("auth feature controls", () => { + it("uses safe defaults for a cold or missing snapshot", () => { + expect(resolveAuthFeatureControls(undefined)).toEqual({ + additionalApiKeyLookupEnabled: false, + }); + }); + + it("accepts only strict booleans and locks org overrides", () => { + const flag = FEATURE_FLAG.additionalApiKeyLookupEnabled; + expect(FeatureFlagCatalog[flag].safeParse(true).success).toBe(true); + // Strict z.boolean(): the stringified "false" must not coerce to true. + expect(FeatureFlagCatalog[flag].safeParse("false").success).toBe(false); + expect(ORG_LOCKED_FLAGS).toContain(flag); + }); +}); diff --git a/apps/webapp/test/environmentVariableApiAccess.test.ts b/apps/webapp/test/environmentVariableApiAccess.test.ts new file mode 100644 index 0000000000..703df55895 --- /dev/null +++ b/apps/webapp/test/environmentVariableApiAccess.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const authMocks = vi.hoisted(() => ({ + authenticateRequest: vi.fn<(...args: any[]) => Promise>(), + authenticateApiKeyWithScope: vi.fn<(...args: any[]) => Promise>(), +})); + +vi.mock("~/services/apiAuth.server", () => authMocks); +vi.mock("~/services/rbac.server", () => ({ + rbac: { authenticatePat: vi.fn(), authenticateUserActor: vi.fn() }, +})); + +import { + authenticateEnvVarApiRequest, + presentedApiKeyFromAuthentication, +} from "~/services/environmentVariableApiAccess.server"; + +describe("presentedApiKeyFromAuthentication", () => { + it("returns the API key that authenticated the request", () => { + expect( + presentedApiKeyFromAuthentication({ + type: "apiKey", + result: { + ok: true, + apiKey: "tr_prod_sk_presented", + type: "PRIVATE", + environment: {} as never, + }, + }) + ).toBe("tr_prod_sk_presented"); + }); + + it("does not exchange user tokens for an API key", () => { + expect( + presentedApiKeyFromAuthentication({ + type: "personalAccessToken", + result: { userId: "user_123" } as never, + }) + ).toBeUndefined(); + }); +}); + +describe("authenticateEnvVarApiRequest", () => { + beforeEach(() => { + authMocks.authenticateRequest.mockReset(); + authMocks.authenticateApiKeyWithScope.mockReset(); + }); + + it.each([ + { type: "personalAccessToken", result: { userId: "user_123" } }, + { type: "organizationAccessToken", result: { organizationId: "org_123" } }, + ])("preserves $type authentication", async (authentication) => { + authMocks.authenticateRequest.mockResolvedValue(authentication); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer token" }, + }); + + await expect(authenticateEnvVarApiRequest(request, "read")).resolves.toEqual({ + ok: true, + authentication, + }); + expect(authMocks.authenticateRequest).toHaveBeenCalledWith(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); + expect(authMocks.authenticateApiKeyWithScope).not.toHaveBeenCalled(); + }); + + it("routes API-key credentials through scoped controller authentication", async () => { + const authentication = { + ok: true, + apiKey: "tr_test_key", + type: "PRIVATE", + environment: { id: "env_123" }, + ability: { can: vi.fn(() => true) }, + }; + authMocks.authenticateRequest.mockResolvedValue(undefined); + authMocks.authenticateApiKeyWithScope.mockResolvedValue({ ok: true, authentication }); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer tr_test_key" }, + }); + + await expect(authenticateEnvVarApiRequest(request, "write")).resolves.toEqual({ + ok: true, + authentication: { type: "apiKey", result: authentication }, + }); + expect(authMocks.authenticateApiKeyWithScope).toHaveBeenCalledWith(request, { + action: "write", + resource: { type: "envvars" }, + }); + }); + + it("preserves scoped controller failures", async () => { + authMocks.authenticateRequest.mockResolvedValue(undefined); + authMocks.authenticateApiKeyWithScope.mockResolvedValue({ + ok: false, + status: 403, + error: "Unauthorized", + }); + + await expect( + authenticateEnvVarApiRequest( + new Request("https://example.com", { + headers: { Authorization: "Bearer tr_test_key" }, + }), + "read" + ) + ).resolves.toEqual({ ok: false, status: 403, error: "Unauthorized" }); + }); +}); diff --git a/apps/webapp/test/findEnvironmentByApiKey.test.ts b/apps/webapp/test/findEnvironmentByApiKey.test.ts index 5bbc1e9783..c301a02b20 100644 --- a/apps/webapp/test/findEnvironmentByApiKey.test.ts +++ b/apps/webapp/test/findEnvironmentByApiKey.test.ts @@ -1,7 +1,8 @@ import { postgresTest } from "@internal/testcontainers"; import { type PrismaClient } from "@trigger.dev/database"; -import { describe, expect, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { findEnvironmentByApiKey } from "~/models/runtimeEnvironment.server"; +import { generateAdditionalApiKey, hashApiKey } from "~/utils/apiKeys"; import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; vi.setConfig({ testTimeout: 60_000 }); @@ -161,4 +162,208 @@ describe("findEnvironmentByApiKey — non-branchable", () => { const resolved = await findEnvironmentByApiKey("tr_dev_nonexistent", undefined, prisma); expect(resolved).toBeNull(); }); + + it("queries only the additional-key store for a valid additional-key format", async () => { + const runtimeEnvironmentFind = vi.fn(); + const revokedApiKeyFind = vi.fn(); + const apiKeyFind = vi.fn(async () => null); + const tx = { + runtimeEnvironment: { findFirst: runtimeEnvironmentFind }, + revokedApiKey: { findFirst: revokedApiKeyFind }, + apiKey: { findFirst: apiKeyFind }, + } as unknown as PrismaClient; + + await expect( + findEnvironmentByApiKey("tr_prod_sk_0123456789abcdefghijklmn", undefined, tx, () => true) + ).resolves.toBeNull(); + expect(apiKeyFind).toHaveBeenCalledOnce(); + expect(runtimeEnvironmentFind).not.toHaveBeenCalled(); + expect(revokedApiKeyFind).not.toHaveBeenCalled(); + }); + + it("skips the additional-key store when lookup is disabled", async () => { + const runtimeEnvironmentFind = vi.fn(); + const revokedApiKeyFind = vi.fn(); + const apiKeyFind = vi.fn(); + const tx = { + runtimeEnvironment: { findFirst: runtimeEnvironmentFind }, + revokedApiKey: { findFirst: revokedApiKeyFind }, + apiKey: { findFirst: apiKeyFind }, + } as unknown as PrismaClient; + + await expect( + findEnvironmentByApiKey("tr_prod_sk_0123456789abcdefghijklmn", undefined, tx, () => false) + ).resolves.toBeNull(); + expect(apiKeyFind).not.toHaveBeenCalled(); + expect(runtimeEnvironmentFind).not.toHaveBeenCalled(); + expect(revokedApiKeyFind).not.toHaveBeenCalled(); + }); + + it.each(["tr_prod_ak_0123456789abcdefghijklmn", "tr_prod_sk_too-short"])( + "keeps malformed additional-key formats on the root lookup path: %s", + async (apiKey) => { + const runtimeEnvironmentFind = vi.fn(async () => null); + const revokedApiKeyFind = vi.fn(async () => null); + const apiKeyFind = vi.fn(); + const tx = { + runtimeEnvironment: { findFirst: runtimeEnvironmentFind }, + revokedApiKey: { findFirst: revokedApiKeyFind }, + apiKey: { findFirst: apiKeyFind }, + } as unknown as PrismaClient; + + await expect(findEnvironmentByApiKey(apiKey, undefined, tx)).resolves.toBeNull(); + expect(runtimeEnvironmentFind).toHaveBeenCalledOnce(); + expect(revokedApiKeyFind).toHaveBeenCalledOnce(); + expect(apiKeyFind).not.toHaveBeenCalled(); + } + ); +}); + +describe("findEnvironmentByApiKey — additional and disabled keys", () => { + postgresTest("authenticates an active additional key", async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const plaintext = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "External integration", + keyHash: hashApiKey(plaintext), + lastFour: plaintext.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + + const resolved = await findEnvironmentByApiKey(plaintext, undefined, prisma, () => true); + + expect(resolved?.id).toBe(environment.id); + expect(resolved?.apiKey).toBe(environment.apiKey); + }); + + postgresTest( + "rejects restricted scopes on the legacy authentication path", + async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const plaintext = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Task-scoped", + keyHash: hashApiKey(plaintext), + lastFour: plaintext.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: "TASK_SELECTION_TEST_PRESET", + scopes: ["trigger:tasks"], + }, + }); + + await expect( + findEnvironmentByApiKey(plaintext, undefined, prisma, () => true) + ).resolves.toBeNull(); + } + ); + + postgresTest("rejects an additional key with empty scopes", async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const plaintext = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Empty policy", + keyHash: hashApiKey(plaintext), + lastFour: plaintext.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: [], + }, + }); + + await expect( + findEnvironmentByApiKey(plaintext, undefined, prisma, () => true) + ).resolves.toBeNull(); + }); + + postgresTest("rejects revoked and expired additional keys", async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const revoked = generateAdditionalApiKey("PRODUCTION").apiKey; + const expired = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.createMany({ + data: [ + { + name: "Revoked", + keyHash: hashApiKey(revoked), + lastFour: revoked.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + revokedAt: new Date(), + }, + { + name: "Expired", + keyHash: hashApiKey(expired), + lastFour: expired.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + expiresAt: new Date(Date.now() - 1_000), + }, + ], + }); + + await expect( + findEnvironmentByApiKey(revoked, undefined, prisma, () => true) + ).resolves.toBeNull(); + await expect( + findEnvironmentByApiKey(expired, undefined, prisma, () => true) + ).resolves.toBeNull(); + }); + + postgresTest( + "resolves the same environment from the root key and an additional key", + async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Replacement", + keyHash: hashApiKey(additional), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + + await expect( + findEnvironmentByApiKey(environment.apiKey, undefined, prisma) + ).resolves.toMatchObject({ id: environment.id }); + await expect( + findEnvironmentByApiKey(additional, undefined, prisma, () => true) + ).resolves.toMatchObject({ id: environment.id }); + } + ); }); diff --git a/apps/webapp/test/mollifierResolveRunForMutation.test.ts b/apps/webapp/test/mollifierResolveRunForMutation.test.ts index b50d8ad940..0b18095319 100644 --- a/apps/webapp/test/mollifierResolveRunForMutation.test.ts +++ b/apps/webapp/test/mollifierResolveRunForMutation.test.ts @@ -19,11 +19,13 @@ import type { BufferEntry, MollifierBuffer } from "@trigger.dev/redis-worker"; const NOW = new Date("2026-05-21T10:00:00Z"); -function fakeReplica(row: { friendlyId: string } | null) { - return { taskRun: { findFirst: vi.fn(async () => row) } }; +function fakeReplica(row: { friendlyId: string; taskIdentifier?: string } | null) { + const result = row ? { ...row, taskIdentifier: row.taskIdentifier ?? "task-1" } : null; + return { taskRun: { findFirst: vi.fn(async () => result) } }; } -function fakeWriter(row: { friendlyId: string } | null) { - return { taskRun: { findFirst: vi.fn(async () => row) } }; +function fakeWriter(row: { friendlyId: string; taskIdentifier?: string } | null) { + const result = row ? { ...row, taskIdentifier: row.taskIdentifier ?? "task-1" } : null; + return { taskRun: { findFirst: vi.fn(async () => result) } }; } function fakeBuffer(entry: BufferEntry | null): MollifierBuffer { @@ -47,7 +49,7 @@ describe("resolveRunForMutation", () => { getBuffer: () => null, }, }); - expect(result).toEqual({ source: "pg", friendlyId: "run_1" }); + expect(result).toEqual({ source: "pg", friendlyId: "run_1", taskIdentifier: "task-1" }); }); it("returns { source: 'buffer' } when PG misses and the buffer entry matches env+org", async () => { @@ -55,7 +57,7 @@ describe("resolveRunForMutation", () => { runId: "run_1", envId: "env_a", orgId: "org_1", - payload: "{}", + payload: JSON.stringify({ taskIdentifier: "task-1" }), status: "QUEUED", attempts: 0, createdAt: NOW, @@ -71,7 +73,11 @@ describe("resolveRunForMutation", () => { getBuffer: () => fakeBuffer(entry), }, }); - expect(result).toEqual({ source: "buffer", friendlyId: "run_1" }); + expect(result).toEqual({ + source: "buffer", + friendlyId: "run_1", + taskIdentifier: "task-1", + }); }); it("returns null when PG misses and the buffer entry env doesn't match", async () => { diff --git a/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts b/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts new file mode 100644 index 0000000000..62ca5c9b05 --- /dev/null +++ b/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authenticateEnvironmentScopedApiRequest: 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, + authorizePatEnvironmentAccess: mocks.authorizePatEnvironmentAccess, + presentedApiKeyFromAuthentication: (authentication: any) => + authentication.type === "apiKey" && authentication.result.ok + ? authentication.result.apiKey + : undefined, +})); +vi.mock("~/services/apiAuth.server", () => ({ + authenticatedEnvironmentForAuthentication: mocks.authenticatedEnvironmentForAuthentication, + branchNameFromRequest: vi.fn(() => undefined), +})); +vi.mock("~/env.server", () => ({ + env: { API_ORIGIN: "https://api.example.com", APP_ORIGIN: "https://app.example.com" }, +})); +vi.mock("~/services/logger.server", () => ({ + logger: { error: vi.fn() }, +})); + +import { loader } from "~/routes/api.v1.projects.$projectRef.$env"; + +const environment = { + id: "env_123", + apiKey: "tr_prod_root_secret", + type: "PRODUCTION", + organizationId: "org_123", + parentEnvironment: null, + parentEnvironmentId: null, + project: { + id: "proj_123", + name: "Example project", + }, +}; + +function load() { + return loader({ + request: new Request("https://app.example.com/api/v1/projects/proj_ref/prod"), + params: { projectRef: "proj_ref", env: "prod" }, + context: {}, + }); +} + +async function responseJson(response: Response) { + return response.json() as Promise>; +} + +describe("project environment credential response", () => { + beforeEach(() => { + mocks.authenticateEnvironmentScopedApiRequest.mockReset(); + mocks.authorizePatEnvironmentAccess.mockReset(); + mocks.authenticatedEnvironmentForAuthentication.mockReset(); + + mocks.authorizePatEnvironmentAccess.mockResolvedValue(undefined); + mocks.authenticatedEnvironmentForAuthentication.mockResolvedValue(environment); + }); + + it("returns the presented API key", async () => { + mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({ + ok: true, + authentication: { + type: "apiKey", + result: { + ok: true, + apiKey: "tr_prod_sk_presented", + type: "PRIVATE", + environment, + }, + }, + }); + + const response = await load(); + + expect(response.status).toBe(200); + await expect(responseJson(response)).resolves.toMatchObject({ + apiKey: "tr_prod_sk_presented", + projectId: "proj_123", + }); + }); + + it("returns the root key to an authorized user token", async () => { + mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({ + ok: true, + authentication: { + type: "personalAccessToken", + result: { userId: "user_123" }, + }, + }); + + const response = await load(); + + expect(response.status).toBe(200); + await expect(responseJson(response)).resolves.toMatchObject({ + apiKey: "tr_prod_root_secret", + }); + }); +}); diff --git a/apps/webapp/test/publicAccessTokenResponse.test.ts b/apps/webapp/test/publicAccessTokenResponse.test.ts new file mode 100644 index 0000000000..403167cfe1 --- /dev/null +++ b/apps/webapp/test/publicAccessTokenResponse.test.ts @@ -0,0 +1,49 @@ +import { validateJWT } from "@trigger.dev/core/v3/jwt"; +import { describe, expect, it } from "vitest"; +import { publicAccessTokenResponseHeaders } from "~/services/publicAccessTokenResponse.server"; + +describe("publicAccessTokenResponseHeaders", () => { + it("returns a server-signed token with the requested resource scopes", async () => { + const headers = await publicAccessTokenResponseHeaders({ + environment: { + id: "env_123", + apiKey: "tr_prod_root_signing_key", + }, + scopes: ["read:batch:batch_123"], + expirationTime: "1h", + }); + + expect(JSON.parse(headers["x-trigger-jwt-claims"]!)).toEqual({ + sub: "env_123", + pub: true, + }); + + const validation = await validateJWT(headers["x-trigger-jwt"]!, "tr_prod_root_signing_key"); + expect(validation.ok).toBe(true); + if (!validation.ok) return; + expect(validation.payload).toMatchObject({ + sub: "env_123", + pub: true, + scopes: ["read:batch:batch_123"], + }); + }); + + it("uses the parent signing key for branch environments", async () => { + const headers = await publicAccessTokenResponseHeaders({ + environment: { + id: "env_branch", + apiKey: "tr_preview_child_key", + parentEnvironment: { apiKey: "tr_preview_parent_key" }, + }, + scopes: ["write:waitpoints:waitpoint_123"], + expirationTime: "24h", + }); + + await expect( + validateJWT(headers["x-trigger-jwt"]!, "tr_preview_parent_key") + ).resolves.toMatchObject({ ok: true }); + await expect( + validateJWT(headers["x-trigger-jwt"]!, "tr_preview_child_key") + ).resolves.toMatchObject({ ok: false }); + }); +}); diff --git a/apps/webapp/test/rbacFallbackBranch.test.ts b/apps/webapp/test/rbacFallbackBranch.test.ts index f8e90e444c..cf5a9f0b77 100644 --- a/apps/webapp/test/rbacFallbackBranch.test.ts +++ b/apps/webapp/test/rbacFallbackBranch.test.ts @@ -1,7 +1,10 @@ import { postgresTest } from "@internal/testcontainers"; import plugin from "@trigger.dev/rbac"; +import { createHash } from "node:crypto"; +import { generateJWT } from "@trigger.dev/core/v3/jwt"; import { type PrismaClient } from "@trigger.dev/database"; -import { describe, expect, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { generateAdditionalApiKey } from "~/utils/apiKeys"; import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; vi.setConfig({ testTimeout: 60_000 }); @@ -11,8 +14,11 @@ vi.setConfig({ testTimeout: 60_000 }); // mirrors findEnvironmentByApiKey, but is a separate implementation, so it // needs its own coverage. forceFallback skips loading the closed-source plugin // and uses the in-repo fallback directly. -function makeController(prisma: PrismaClient) { - return plugin.create({ primary: prisma, replica: prisma }, { forceFallback: true }); +function makeController(prisma: PrismaClient, additionalApiKeyLookupEnabled?: () => boolean) { + return plugin.create( + { primary: prisma, replica: prisma }, + { forceFallback: true, additionalApiKeyLookupEnabled } + ); } function bearerRequest(apiKey: string, branch?: string) { @@ -143,6 +149,337 @@ describe("RBAC fallback — DEVELOPMENT branch pivot", () => { ); }); +describe("RBAC fallback — additional keys", () => { + it("rejects a disabled additional-key lookup without querying", async () => { + const runtimeEnvironmentFind = vi.fn(); + const revokedApiKeyFind = vi.fn(); + const apiKeyFind = vi.fn(); + const prisma = { + runtimeEnvironment: { findFirst: runtimeEnvironmentFind }, + revokedApiKey: { findFirst: revokedApiKeyFind }, + apiKey: { findFirst: apiKeyFind }, + } as unknown as PrismaClient; + const rbac = makeController(prisma, () => false); + const key = "tr_prod_sk_0123456789abcdefghijklmn"; + + await expect(rbac.authenticateBearer(bearerRequest(key))).resolves.toMatchObject({ + ok: false, + resolution: { + credentialKind: "additional_api_key", + lookupPath: "additional_skipped", + }, + }); + expect(runtimeEnvironmentFind).not.toHaveBeenCalled(); + expect(revokedApiKeyFind).not.toHaveBeenCalled(); + expect(apiKeyFind).not.toHaveBeenCalled(); + }); + + postgresTest("authenticates an additional key and records its use", 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 additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "External integration", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + const rootResult = await rbac.authenticateBearer(bearerRequest(environment.apiKey)); + const additionalResult = await rbac.authenticateBearer(bearerRequest(additional)); + + expect(rootResult.ok).toBe(true); + expect(additionalResult.ok).toBe(true); + if (!additionalResult.ok) return; + expect(additionalResult.environment.id).toBe(environment.id); + expect(additionalResult.environment.apiKey).toBe(environment.apiKey); + await expect( + prisma.apiKey.findFirst({ + where: { keyHash: createHash("sha256").update(additional).digest("hex") }, + select: { lastUsedAt: true }, + }) + ).resolves.toMatchObject({ lastUsedAt: expect.any(Date) }); + }); + + postgresTest("enforces restricted stored scopes on an additional key", 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 additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Task-scoped", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: "TRIGGER_ONLY", + scopes: ["trigger:tasks:send-email"], + }, + }); + + const result = await rbac.authenticateBearer(bearerRequest(additional)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.subject).toMatchObject({ type: "apiKey", restricted: true }); + expect(result.ability.can("trigger", { type: "tasks", id: "send-email" })).toBe(true); + expect(result.ability.can("trigger", { type: "tasks", id: "other-task" })).toBe(false); + expect(result.ability.can("read", { type: "runs" })).toBe(false); + }); + + postgresTest("pivots an additional key to its branch environment", async ({ prisma }) => { + const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const devRoot = await createEnv(prisma, project.id, organization.id, { + type: "DEVELOPMENT", + orgMemberId: orgMember.id, + }); + const branch = await createEnv(prisma, project.id, organization.id, { + type: "DEVELOPMENT", + orgMemberId: orgMember.id, + parentEnvironmentId: devRoot.id, + branchName: "api-key-policy", + }); + const additional = generateAdditionalApiKey("DEVELOPMENT").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Branch key", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: devRoot.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + + const result = await rbac.authenticateBearer(bearerRequest(additional, "api-key-policy")); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.environment.id).toBe(branch.id); + expect(result.environment.parentEnvironment?.id).toBe(devRoot.id); + expect(result.subject).toMatchObject({ type: "apiKey", restricted: false }); + }); + + postgresTest("treats empty stored scopes as restricted and deny-all", 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 additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Empty policy", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: [], + }, + }); + + const result = await rbac.authenticateBearer(bearerRequest(additional)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.subject).toMatchObject({ type: "apiKey", restricted: true }); + expect(result.ability.can("read", { type: "runs" })).toBe(false); + expect(result.ability.can("trigger", { type: "tasks", id: "send-email" })).toBe(false); + }); +}); + +describe("RBAC fallback — public JWTs", () => { + postgresTest( + "keeps tokens signed with a rotated root key valid for the grace window", + async ({ prisma }) => { + const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const token = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "1h", + }); + + await expect( + rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }) + ).resolves.toMatchObject({ ok: true }); + + // Rotate exactly as `regenerateApiKey` does: new value on the env, old + // value parked in RevokedApiKey with a future expiry. + const previousApiKey = environment.apiKey; + await prisma.$transaction([ + prisma.revokedApiKey.create({ + data: { + apiKey: previousApiKey, + runtimeEnvironmentId: environment.id, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }), + prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { apiKey: uniqueId("tr_rotated") }, + }), + ]); + + const graceResult = await rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }); + expect(graceResult.ok).toBe(true); + if (!graceResult.ok) return; + expect(graceResult.environment.id).toBe(environment.id); + expect(graceResult.ability.can("read", { type: "runs" })).toBe(true); + expect(graceResult.ability.can("write", { type: "runs" })).toBe(false); + } + ); + + postgresTest( + "rejects a token signed with a rotated key once the grace window expires", + async ({ prisma }) => { + const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const token = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "1h", + }); + + await prisma.$transaction([ + prisma.revokedApiKey.create({ + data: { + apiKey: environment.apiKey, + runtimeEnvironmentId: environment.id, + expiresAt: new Date(Date.now() - 60 * 1000), + }, + }), + prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { apiKey: uniqueId("tr_rotated") }, + }), + ]); + + await expect( + rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }) + ).resolves.toMatchObject({ ok: false, status: 401 }); + } + ); + + postgresTest("surfaces public JWT actor attribution", 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 token = await generateJWT({ + secretKey: environment.apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["read:runs"], + act: { sub: user.id }, + }, + expirationTime: "1h", + }); + + const result = await rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.jwt?.act).toEqual({ sub: user.id }); + }); + + postgresTest("rejects public JWTs for soft-deleted projects", async ({ prisma }) => { + const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const token = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "1h", + }); + await prisma.project.update({ where: { id: project.id }, data: { deletedAt: new Date() } }); + + const result = await rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }); + + expect(result).toMatchObject({ ok: false, status: 401 }); + }); +}); + +describe("RBAC fallback — additional key permissions", () => { + postgresTest( + "gives additional keys root-key-equivalent permissive access", + 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 additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "External integration", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + + const rootResult = await rbac.authenticateBearer(bearerRequest(environment.apiKey)); + const additionalResult = await rbac.authenticateBearer(bearerRequest(additional)); + + expect(rootResult.ok).toBe(true); + expect(additionalResult.ok).toBe(true); + if (!rootResult.ok || !additionalResult.ok) return; + + expect(additionalResult.subject).toMatchObject({ type: "apiKey", restricted: false }); + for (const [action, resource] of [ + ["write", { type: "envvars" }], + ["trigger", { type: "tasks", id: "send-email" }], + ["read", { type: "runs", id: "run_123" }], + ] as const) { + expect(additionalResult.ability.can(action, resource)).toBe( + rootResult.ability.can(action, resource) + ); + } + } + ); +}); + describe("RBAC fallback — branch header guards", () => { // 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 diff --git a/apps/webapp/test/streamBatchItemsAuthorization.test.ts b/apps/webapp/test/streamBatchItemsAuthorization.test.ts new file mode 100644 index 0000000000..0ea800a104 --- /dev/null +++ b/apps/webapp/test/streamBatchItemsAuthorization.test.ts @@ -0,0 +1,136 @@ +import { buildJwtAbility } from "@trigger.dev/plugins"; +import { describe, expect, it } from "vitest"; +import { withActionAliases } from "@trigger.dev/rbac"; +import { + authorizeBatchItems, + authorizedBatchItemStream, + batchPublicAccessScopes, +} from "~/utils/batchItemAuthorization"; +import { canWriteResolvedParentRun } from "~/utils/parentRunAuthorization"; + +async function collect(items: AsyncIterable): Promise { + const result: unknown[] = []; + for await (const item of items) result.push(item); + return result; +} + +async function* batchItems(...tasks: string[]): AsyncIterable { + for (const task of tasks) yield { task, payload: {} }; +} + +describe("streaming batch item authorization", () => { + it("rejects a selected-task key when any streamed task is unauthorized", async () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + await expect( + collect(authorizeBatchItems(batchItems("task-a", "task-b"), ability, "batch_123")) + ).rejects.toThrow(); + }); + + it("allows all items covered by the key's task grants", async () => { + const ability = withActionAliases( + buildJwtAbility(["batchTrigger:tasks:task-a", "batchTrigger:tasks:task-b"]) + ); + + await expect( + collect(authorizeBatchItems(batchItems("task-a", "task-b"), ability, "batch_123")) + ).resolves.toHaveLength(2); + }); + + it("preserves a public JWT's write grant for the batch", async () => { + const ability = withActionAliases(buildJwtAbility(["write:batch:batch_123"])); + + await expect( + collect(authorizeBatchItems(batchItems("task-a", "task-b"), ability, "batch_123")) + ).resolves.toHaveLength(2); + }); + + it("does not delegate batch-wide writes from selected-task credentials", () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + expect(batchPublicAccessScopes("batch_123", ability, true)).toEqual(["read:batch:batch_123"]); + }); + + it("delegates batch-wide writes when the credential can trigger every task", () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks"])); + + expect(batchPublicAccessScopes("batch_123", ability, true)).toEqual([ + "read:batch:batch_123", + "write:batch:batch_123", + ]); + }); + + it("rejects an empty stream from a credential with no batch-level write grant", async () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + // An empty stream authorizes nothing, so it must not reach the service — + // whose batch lookup would otherwise report the batch's existence and status. + await expect(authorizedBatchItemStream(batchItems(), ability, "batch_123")).rejects.toThrow(); + }); + + it("allows an empty stream from a credential that can write the batch", async () => { + const ability = withActionAliases(buildJwtAbility(["write:batch:batch_123"])); + + const stream = await authorizedBatchItemStream(batchItems(), ability, "batch_123"); + + await expect(collect(stream)).resolves.toHaveLength(0); + }); + + it("still authorizes every item when the first one passes", async () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + const stream = await authorizedBatchItemStream( + batchItems("task-a", "task-b"), + ability, + "batch_123" + ); + + await expect(collect(stream)).rejects.toThrow(); + }); + + it("replays the eagerly-pulled first item to the consumer", async () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + const stream = await authorizedBatchItemStream( + batchItems("task-a", "task-a"), + ability, + "batch_123" + ); + + await expect(collect(stream)).resolves.toEqual([ + { task: "task-a", payload: {} }, + { task: "task-a", payload: {} }, + ]); + }); + + it("treats a type-level write:runs grant as covering every parent run", () => { + // The route-level `canWriteParentRun` short-circuits on this, skipping a DB + // lookup per distinct parent. Assert the premise holds: the grant really + // does authorize an arbitrary run id. + const ability = withActionAliases(buildJwtAbility(["write:runs"])); + + expect( + canWriteResolvedParentRun(ability, { + friendlyId: "run_anything", + taskIdentifier: "some-task-we-have-no-grant-for", + }) + ).toBe(true); + }); + + it("only allows selected-task operators to link parent runs for their tasks", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks:task-a"])); + + expect( + canWriteResolvedParentRun(ability, { + friendlyId: "run_a", + taskIdentifier: "task-a", + }) + ).toBe(true); + expect( + canWriteResolvedParentRun(ability, { + friendlyId: "run_b", + taskIdentifier: "task-b", + }) + ).toBe(false); + }); +}); diff --git a/internal-packages/rbac/src/apiKeyPolicies.test.ts b/internal-packages/rbac/src/apiKeyPolicies.test.ts new file mode 100644 index 0000000000..015ba1252e --- /dev/null +++ b/internal-packages/rbac/src/apiKeyPolicies.test.ts @@ -0,0 +1,325 @@ +import { extractJWTSub, isPublicJWT } from "@trigger.dev/core/v3/jwt"; +import type { PrismaClient } from "@trigger.dev/database"; +import { + scopesGrantFullAccess, + type BearerAuthResult, + type RoleBaseAccessController, +} from "@trigger.dev/plugins"; +import { describe, expect, it, vi } from "vitest"; +import { buildJwtAbility } from "./ability.js"; +import loader, { resolveJwtSigningKey } from "./index.js"; + +type AuthSuccess = Extract; + +type LazyControllerInternals = { + _init: Promise; + _hostCredentialResolver: { + authenticate: RoleBaseAccessController["authenticateBearer"]; + }; +}; + +const prismaPlaceholder = {} as PrismaClient; +const ADDITIONAL_API_KEY = "tr_prod_sk_0123456789abcdefghijklmn"; +const ROOT_API_KEY = "tr_prod_0123456789abcdefghijklmn"; +const environment = { + id: "env_123", + organizationId: "org_123", + projectId: "proj_123", + parentEnvironment: null, + parentEnvironmentId: null, +} as unknown as AuthSuccess["environment"]; + +function installPlugin( + plugin: RoleBaseAccessController, + hostAuthenticate: RoleBaseAccessController["authenticateBearer"] +) { + const controller = loader.create(prismaPlaceholder, { forceFallback: true }); + const internals = controller as unknown as LazyControllerInternals; + internals._init = Promise.resolve(plugin); + internals._hostCredentialResolver = { authenticate: hostAuthenticate }; + return controller; +} + +function additionalKeyResult(scopes: string[]): AuthSuccess { + return { + ok: true, + environment, + subject: { + type: "apiKey", + apiKeyId: "key_123", + restricted: !scopesGrantFullAccess(scopes), + organizationId: environment.organizationId, + projectId: environment.projectId, + }, + ability: buildJwtAbility(scopes), + }; +} + +function rootKeyResult(): AuthSuccess { + return { + ok: true, + environment, + subject: { + type: "user", + userId: "user_123", + organizationId: environment.organizationId, + projectId: environment.projectId, + }, + ability: buildJwtAbility(["admin"]), + }; +} + +function publicJwtResult(): AuthSuccess { + return { + ok: true, + environment, + subject: { + type: "publicJWT", + environmentId: environment.id, + organizationId: environment.organizationId, + projectId: environment.projectId, + }, + ability: buildJwtAbility(["read:runs"]), + }; +} + +function publicJwt(payload: Record) { + return `header.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.signature`; +} + +function bearerRequest(token: string) { + return new Request("https://api.trigger.dev/test", { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +describe("API-key policy controller composition", () => { + it("routes public JWTs directly to the host without calling the plugin authenticator", async () => { + const pluginAuthenticate = vi.fn(); + const hostAuthenticate = vi.fn(async () => publicJwtResult()); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + const token = publicJwt({ pub: true, sub: environment.id }); + + const result = await controller.authenticateBearer( + new Request("https://api.trigger.dev/test", { + headers: { Authorization: `Bearer ${token}` }, + }), + { allowJWT: true } + ); + + expect(result.ok).toBe(true); + expect(hostAuthenticate).toHaveBeenCalledOnce(); + expect(pluginAuthenticate).not.toHaveBeenCalled(); + }); + + it("routes valid additional keys directly to the host and preserves their scopes", async () => { + const pluginAuthenticate = vi.fn(); + const hostAuthenticate = vi.fn(async () => additionalKeyResult(["write:tasks:send-email"])); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + + const result = await controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY)); + + expect(result.ok).toBe(true); + expect(hostAuthenticate).toHaveBeenCalledOnce(); + expect(pluginAuthenticate).not.toHaveBeenCalled(); + if (!result.ok) return; + expect(result.subject).toMatchObject({ type: "apiKey", restricted: true }); + expect(result.ability.can("trigger", { type: "tasks", id: "send-email" })).toBe(true); + expect(result.ability.can("trigger", { type: "tasks", id: "other-task" })).toBe(false); + expect(result.ability.can("read", { type: "runs" })).toBe(false); + }); + + it("returns an unknown additional key's host 401 without calling the plugin", async () => { + const hostFailure = { + ok: false as const, + status: 401 as const, + error: "Invalid API key", + }; + const pluginAuthenticate = vi.fn(); + const hostAuthenticate = vi.fn(async () => hostFailure); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + + await expect( + controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY)) + ).resolves.toMatchObject(hostFailure); + expect(hostAuthenticate).toHaveBeenCalledOnce(); + expect(pluginAuthenticate).not.toHaveBeenCalled(); + }); + + it("routes current root keys to the plugin without calling the host", async () => { + const pluginAuthenticate = vi.fn(async () => rootKeyResult()); + const hostAuthenticate = vi.fn(); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + + await expect(controller.authenticateBearer(bearerRequest(ROOT_API_KEY))).resolves.toMatchObject( + { + ok: true, + subject: { type: "user" }, + } + ); + expect(pluginAuthenticate).toHaveBeenCalledOnce(); + expect(hostAuthenticate).not.toHaveBeenCalled(); + }); + + it.each([401, 403] as const)( + "returns a plugin %s for root-shaped keys without calling the host", + async (status) => { + const pluginFailure = { + ok: false as const, + status, + error: "Unauthorized", + }; + const pluginAuthenticate = vi.fn(async () => pluginFailure); + const hostAuthenticate = vi.fn(); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + + await expect( + controller.authenticateBearer(bearerRequest(ROOT_API_KEY)) + ).resolves.toMatchObject(pluginFailure); + expect(pluginAuthenticate).toHaveBeenCalledOnce(); + expect(hostAuthenticate).not.toHaveBeenCalled(); + } + ); + + it("fails closed when an additional-key host route resolves a non-apiKey subject", async () => { + const pluginAuthenticate = vi.fn(); + const hostAuthenticate = vi.fn(async () => rootKeyResult()); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + + await expect( + controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY)) + ).resolves.toMatchObject({ + ok: false, + status: 401, + error: "Invalid API key", + }); + expect(pluginAuthenticate).not.toHaveBeenCalled(); + }); + + it("keeps additional-key authentication in the no-plugin fallback controller", async () => { + const fallbackAuthenticate = vi.fn(async () => additionalKeyResult(["admin"])); + const hostAuthenticate = vi.fn(); + const fallback = { + isUsingPlugin: vi.fn(async () => false), + authenticateBearer: fallbackAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(fallback, hostAuthenticate); + + await expect( + controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY)) + ).resolves.toMatchObject({ ok: true, subject: { type: "apiKey" } }); + expect(fallbackAuthenticate).toHaveBeenCalledOnce(); + expect(hostAuthenticate).not.toHaveBeenCalled(); + }); + + it("delegates API-key policy catalogue, preparation, and description", async () => { + const presets = vi.fn(async () => []); + const prepare = vi.fn(async () => ({ + ok: true as const, + policy: { presetId: "FULL_ACCESS", scopes: ["admin"] }, + })); + const describePolicy = vi.fn(async () => ({ taskIdentifiers: ["send-email"] })); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + apiKeyPresets: presets, + prepareApiKeyPolicy: prepare, + describeApiKeyPolicy: describePolicy, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, vi.fn()); + const prepareParams = { organizationId: "org_123", presetId: "FULL_ACCESS" }; + const policy = { presetId: "TASKS", scopes: ["trigger:tasks:send-email"] }; + + await controller.apiKeyPresets("org_123"); + await controller.prepareApiKeyPolicy(prepareParams); + await controller.describeApiKeyPolicy(policy); + + expect(presets).toHaveBeenCalledWith("org_123"); + expect(prepare).toHaveBeenCalledWith(prepareParams); + expect(describePolicy).toHaveBeenCalledWith(policy); + }); +}); + +describe("API-key policy fallback", () => { + it("prepares explicit standalone full access and exposes no preset catalogue", async () => { + const controller = loader.create(prismaPlaceholder, { forceFallback: true }); + + await expect(controller.apiKeyPresets("org_123")).resolves.toBeNull(); + await expect( + controller.prepareApiKeyPolicy({ organizationId: "org_123", presetId: "FULL_ACCESS" }) + ).resolves.toEqual({ + ok: true, + policy: { presetId: null, scopes: ["admin"] }, + }); + await expect( + controller.describeApiKeyPolicy({ presetId: null, scopes: ["admin"] }) + ).resolves.toEqual({}); + }); + + it("rejects restricted presets and task input without a plugin", async () => { + const controller = loader.create(prismaPlaceholder, { forceFallback: true }); + const unavailable = { ok: false, error: "API key access presets are not available" }; + + // Anything other than full access is a restricted key, which needs the plugin. + await expect( + controller.prepareApiKeyPolicy({ organizationId: "org_123", presetId: "TRIGGER_ONLY" }) + ).resolves.toEqual(unavailable); + await expect( + controller.prepareApiKeyPolicy({ + organizationId: "org_123", + presetId: "FULL_ACCESS", + taskIdentifiers: ["send-email"], + }) + ).resolves.toEqual(unavailable); + }); +}); + +describe("scope policy helpers", () => { + it("recognizes only exact bare admin as full access", () => { + expect(scopesGrantFullAccess(["admin"])).toBe(true); + expect(scopesGrantFullAccess(["read:runs", "admin"])).toBe(true); + expect(scopesGrantFullAccess([])).toBe(false); + expect(scopesGrantFullAccess(["admin:runs"])).toBe(false); + expect(scopesGrantFullAccess(["*:all"])).toBe(false); + }); +}); + +describe("JWT host helpers", () => { + it("recognizes public JWTs and extracts their subject", () => { + const token = publicJwt({ pub: true, sub: "env_123" }); + expect(isPublicJWT(token)).toBe(true); + expect(extractJWTSub(token)).toBe("env_123"); + expect(isPublicJWT("not-a-jwt")).toBe(false); + expect(extractJWTSub("not-a-jwt")).toBeUndefined(); + }); + + it("uses the parent key as branch JWT-signing material", () => { + expect(resolveJwtSigningKey({ apiKey: "root" })).toBe("root"); + expect(resolveJwtSigningKey({ apiKey: "child", parentEnvironment: { apiKey: "parent" } })).toBe( + "parent" + ); + }); +}); diff --git a/internal-packages/rbac/src/bearerCredentials.ts b/internal-packages/rbac/src/bearerCredentials.ts new file mode 100644 index 0000000000..64dcd82372 --- /dev/null +++ b/internal-packages/rbac/src/bearerCredentials.ts @@ -0,0 +1,402 @@ +import type { BearerAuthResult, RbacEnvironment, RbacSubject } from "@trigger.dev/plugins"; +import { createHash } from "node:crypto"; +import type { PrismaClient } from "@trigger.dev/database"; +import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; +import { extractJWTSub, isPublicJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; +import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { scopesGrantFullAccess } from "@trigger.dev/plugins"; +import { buildJwtAbility, permissiveAbility } from "./ability.js"; + +export type BearerCredentialClients = { + // Used for the `lastUsedAt` telemetry write; reads go to the replica. + primary: PrismaClient; + replica: PrismaClient; +}; + +export type BearerCredentialKind = + | "root_api_key" + | "additional_api_key" + | "public_jwt" + | "legacy_public_key" + | "unknown"; + +export type BearerLookupPath = + | "plugin" + | "root_current" + | "root_rotated" + | "additional" + | "additional_skipped" + | "jwt_current" + | "jwt_rotated" + | "legacy_public" + | "not_found"; + +export type BearerResolution = { + credentialKind: BearerCredentialKind; + lookupPath: BearerLookupPath; +}; + +export type BearerCredentialResult = BearerAuthResult & { + resolution: BearerResolution; +}; + +// JWT-signing material. Today this is the environment's root apiKey (or the +// parent's, for branch envs) — which is why rotating a root key needs the +// grace-window retry in `authenticate`. When a dedicated per-environment +// signing secret lands, this is the only credential-resolution seam that needs +// to change. +export function resolveJwtSigningKey(env: { + apiKey: string; + parentEnvironment?: { apiKey: string } | null; +}): string { + return env.parentEnvironment?.apiKey ?? env.apiKey; +} + +/** + * Resolves an incoming bearer token into a `BearerAuthResult`: a public JWT, a + * root environment API key, a grace-window rotated key, or a host-owned + * additional API key (the `ApiKey` table). + * + * This is deliberately NOT part of the "no-plugin fallback". Additional API + * keys are owned by the host, not by the optional RBAC plugin, so this + * resolution is always-on and is composed by *both* the fallback controller + * and the plugin-backed controller (which delegates host-owned keys here). + * + * Public JWTs carry inline scopes, which are compiled into an ability here. + * Additional API-key credentials carry host-persisted effective scopes, which + * are compiled into their final ability here without a plugin policy lookup. + */ +export class BearerCredentialResolver { + private readonly prisma: PrismaClient; + private readonly replica: PrismaClient; + + constructor( + clients: BearerCredentialClients, + private readonly additionalApiKeyLookupEnabled: () => boolean = () => true + ) { + this.prisma = clients.primary; + this.replica = clients.replica; + } + + async authenticate( + request: Request, + options?: { allowJWT?: boolean } + ): Promise { + // Deprecated public API keys (`pk_*` minted long before public JWTs + // landed) are intentionally NOT handled here. That token format hasn't + // been issued for years; any `pk_*` bearer on an apiBuilder route returns + // 401. Public access goes through the JWT path (`isPublicJWT`) instead. + const rawToken = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + if (!rawToken) { + return { + ok: false, + status: 401, + error: "Invalid or Missing API key", + resolution: { credentialKind: "unknown", lookupPath: "not_found" }, + }; + } + + if (options?.allowJWT && isPublicJWT(rawToken)) { + const envId = extractJWTSub(rawToken); + if (!envId) { + return { + ok: false, + status: 401, + error: "Invalid Public Access Token", + resolution: { credentialKind: "public_jwt", lookupPath: "not_found" }, + }; + } + + // Match the include shape of the slim AuthenticatedEnvironment so + // the bridge can use the returned env without a follow-up fetch. + const env = await this.replica.runtimeEnvironment.findFirst({ + where: { id: envId }, + include: { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, + parentEnvironment: { + select: { id: true, apiKey: true }, + }, + }, + }); + if (!env || env.project.deletedAt !== null) { + return { + ok: false, + status: 401, + error: "Invalid Public Access Token", + resolution: { credentialKind: "public_jwt", lookupPath: "not_found" }, + }; + } + + let lookupPath: BearerLookupPath = "jwt_current"; + let result = await validateJWT(rawToken, resolveJwtSigningKey(env)); + + // Root-key rotation grace window, mirroring the bearer path below: a + // rotated key keeps authenticating until its `RevokedApiKey` row expires, + // so tokens *signed* with that key have to keep verifying for just as + // long. Otherwise rotating a root key silently invalidates every + // outstanding public access token in the environment. + // + // Only retried on a signature mismatch — an expired or malformed token + // fails for a reason no other signing key can fix, and this runs on every + // rejected public token. + if (!result.ok && result.code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") { + // `resolveJwtSigningKey` signs a branch with its parent's key, so the + // grace-window rows to consult belong to whichever env owns that key. + const signingEnvironmentId = env.parentEnvironment?.id ?? env.id; + const revoked = await this.replica.revokedApiKey.findMany({ + where: { + runtimeEnvironmentId: signingEnvironmentId, + expiresAt: { gt: new Date() }, + }, + select: { apiKey: true }, + }); + + if (revoked.length > 0) lookupPath = "jwt_rotated"; + + for (const candidate of revoked) { + const retried = await validateJWT(rawToken, candidate.apiKey); + if (retried.ok) { + result = retried; + break; + } + } + } + + if (!result.ok) { + return { + ok: false, + status: 401, + error: "Public Access Token is invalid", + resolution: { credentialKind: "public_jwt", lookupPath }, + }; + } + + const scopes = Array.isArray(result.payload.scopes) + ? (result.payload.scopes as string[]) + : []; + const realtime = result.payload.realtime as { skipColumns?: string[] } | undefined; + const oneTimeUse = result.payload.otu === true; + // A JWT minted from a PAT/UAT exchange stamps `act: { sub: userId }` for + // attribution. Surface it so write handlers can record the acting user. + const act = result.payload.act as { sub?: unknown } | undefined; + const actSub = typeof act?.sub === "string" ? act.sub : undefined; + + return { + ok: true, + environment: toAuthenticatedEnvironment(env), + subject: { + type: "publicJWT", + environmentId: env.id, + organizationId: env.organizationId, + projectId: env.projectId, + }, + ability: buildJwtAbility(scopes), + jwt: { realtime, oneTimeUse, ...(actSub ? { act: { sub: actSub } } : {}) }, + resolution: { credentialKind: "public_jwt", lookupPath }, + }; + } + + // PREVIEW (and DEVELOPMENT) envs are parents — operating "on a branch" means routing + // to a child env keyed by branchName. The customer authenticates + // with the parent's apiKey + an `x-trigger-branch` header. Include the + // matching child env so the pivot below can adopt its identity. + const branchName = sanitizeBranchName(request.headers.get("x-trigger-branch")); + const include = { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, + parentEnvironment: { select: { id: true, apiKey: true } }, + childEnvironments: branchName ? { where: { branchName, archivedAt: null } } : undefined, + } as const; + const now = new Date(); + const routesToAdditionalKey = isAdditionalApiKey(rawToken); + if (routesToAdditionalKey && !this.additionalApiKeyLookupEnabled()) { + return { + ok: false, + status: 401, + error: "Invalid API key", + resolution: { + credentialKind: "additional_api_key", + lookupPath: "additional_skipped", + }, + }; + } + + let rootEnvironment = routesToAdditionalKey + ? null + : await this.replica.runtimeEnvironment.findFirst({ + where: { apiKey: rawToken }, + include, + }); + + // Revoked API key grace window — recently rotated keys keep working until + // their `expiresAt`; without this a customer who rotates an env API key + // gets immediate 401s on the new auth path. + let resolvedRotatedRoot = false; + if (!routesToAdditionalKey && !rootEnvironment) { + const revoked = await this.replica.revokedApiKey.findFirst({ + where: { + apiKey: rawToken, + expiresAt: { gt: now }, + }, + include: { runtimeEnvironment: { include } }, + }); + rootEnvironment = revoked?.runtimeEnvironment ?? null; + resolvedRotatedRoot = rootEnvironment !== null; + } + + const match = routesToAdditionalKey + ? await this.replica.apiKey.findFirst({ + where: { + keyHash: createHash("sha256").update(rawToken, "utf8").digest("hex"), + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + select: { + id: true, + lastUsedAt: true, + scopes: true, + runtimeEnvironment: { include }, + }, + }) + : null; + const additionalApiKey = match + ? { id: match.id, lastUsedAt: match.lastUsedAt, scopes: match.scopes } + : null; + let env = rootEnvironment ?? match?.runtimeEnvironment ?? null; + + if (!env || env.project.deletedAt !== null) { + return { + ok: false, + status: 401, + error: "Invalid API key", + resolution: { + credentialKind: routesToAdditionalKey ? "additional_api_key" : "root_api_key", + lookupPath: routesToAdditionalKey ? "additional" : "not_found", + }, + }; + } + + if ( + additionalApiKey && + (!additionalApiKey.lastUsedAt || + additionalApiKey.lastUsedAt < new Date(now.getTime() - 300_000)) + ) { + try { + await this.prisma.apiKey.updateMany({ + where: { + id: additionalApiKey.id, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + data: { lastUsedAt: now }, + }); + } catch { + // Authentication should not fail because last-used telemetry could not be recorded. + } + } + + if (env.type === "PREVIEW" && !branchName) { + return { + ok: false, + status: 401, + error: "x-trigger-branch header required for preview env", + resolution: bearerResolution(additionalApiKey !== null, resolvedRotatedRoot), + }; + } + + if (env.type === "PREVIEW" || env.type === "DEVELOPMENT") { + // The "default" root branch is DEVELOPMENT-only: it maps to the dev root env + // (which carries no branch), so we skip the pivot there. For PREVIEW, + // "default" is an ordinary branch name and must still pivot to its child. + const isDevAndDefault = env.type === "DEVELOPMENT" && isDefaultDevBranch(branchName); + if (branchName !== null && !isDevAndDefault) { + const child = env.childEnvironments?.[0]; + if (!child) { + return { + ok: false, + status: 401, + error: "No matching branch env", + resolution: bearerResolution(additionalApiKey !== null, resolvedRotatedRoot), + }; + } + // Pivot to the child env: child's id/type/branchName, parent's + // apiKey/orgMember/organization/project. + env = { + ...child, + apiKey: env.apiKey, + orgMember: env.orgMember, + organization: env.organization, + project: env.project, + parentEnvironment: { id: env.id, apiKey: env.apiKey }, + childEnvironments: [], + }; + } + } + + // An additional (ApiKey-table) key is a first-class `apiKey` principal. + // Root/legacy environment keys keep the `user` subject (they're on their + // way out once additional keys fully replace them). + const subject: RbacSubject = additionalApiKey + ? { + type: "apiKey", + apiKeyId: additionalApiKey.id, + restricted: !scopesGrantFullAccess(additionalApiKey.scopes), + organizationId: env.organizationId, + projectId: env.projectId, + } + : { + type: "user", + userId: env.orgMember?.userId ?? "", + organizationId: env.organizationId, + projectId: env.projectId, + }; + + return { + ok: true, + environment: toAuthenticatedEnvironment(env), + subject, + ability: additionalApiKey ? buildJwtAbility(additionalApiKey.scopes) : permissiveAbility, + resolution: bearerResolution(additionalApiKey !== null, resolvedRotatedRoot), + }; + } +} + +function bearerResolution( + additionalApiKey: boolean, + resolvedRotatedRoot: boolean +): BearerResolution { + return additionalApiKey + ? { credentialKind: "additional_api_key", lookupPath: "additional" } + : { + credentialKind: "root_api_key", + lookupPath: resolvedRotatedRoot ? "root_rotated" : "root_current", + }; +} + +// Coerce a Prisma RuntimeEnvironment payload (with project/organization/ +// orgMember/parentEnvironment includes) into the slim AuthenticatedEnvironment +// the auth contract carries. Explicit coercion keeps +// `concurrencyLimitBurstFactor` a plain number across the auth boundary. +function toAuthenticatedEnvironment(env: RbacEnvironment): RbacEnvironment { + const burst = env.concurrencyLimitBurstFactor; + return { + ...env, + concurrencyLimitBurstFactor: typeof burst === "number" ? burst : burst.toNumber(), + }; +} diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index 4cf1e765c7..9054c0a43e 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -1,7 +1,6 @@ import type { Permission, Role, - RbacEnvironment, RbacUser, RbacSubject, RbacResource, @@ -20,9 +19,8 @@ import { } from "@trigger.dev/plugins"; import { createHash } from "node:crypto"; import type { PrismaClient } from "@trigger.dev/database"; -import { validateJWT } from "@trigger.dev/core/v3/jwt"; -import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; -import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js"; +import { buildFallbackAbility, permissiveAbility } from "./ability.js"; +import { BearerCredentialResolver } from "./bearerCredentials.js"; export type FallbackPrismaClients = { // Used for writes (setUserRole, mutateRole, etc.) and any reads that @@ -48,6 +46,7 @@ function resolvePrismaClients(input: PrismaInput): FallbackPrismaClients { export type FallbackOptions = { // Platform secret for verifying delegated user-actor tokens (tr_uat_). userActorSecret?: string; + additionalApiKeyLookupEnabled?: () => boolean; }; export class RoleBaseAccessFallback { @@ -68,11 +67,15 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { private readonly prisma: PrismaClient; // alias for primary — used by writes private readonly replica: PrismaClient; private readonly userActorSecret?: string; + // Bearer-token resolution (JWTs, root keys, additional API keys) is always-on + // host logic, not an RBAC default — see bearerCredentials.ts. + private readonly bearer: BearerCredentialResolver; constructor(clients: FallbackPrismaClients, options?: FallbackOptions) { this.prisma = clients.primary; this.replica = clients.replica; this.userActorSecret = options?.userActorSecret; + this.bearer = new BearerCredentialResolver(clients, options?.additionalApiKeyLookupEnabled); } async isUsingPlugin(): Promise { @@ -83,166 +86,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { request: Request, options?: { allowJWT?: boolean } ): Promise { - // Deprecated public API keys (`pk_*` minted long before public JWTs - // landed) are intentionally NOT handled here. The legacy - // `findEnvironmentByPublicApiKey` path looked them up via the - // `pkApiKey` column, but that token format hasn't been issued for - // years and no live client should be sending one. Any `pk_*` bearer - // on a route that goes through the apiBuilder now returns 401 — - // public access goes through the JWT path (`isPublicJWT(rawToken)` - // below) instead. The deprecated lookup is still exported from - // `apps/webapp/app/models/runtimeEnvironment.server.ts` for the - // pre-RBAC routes that haven't been migrated, but it's a dead - // code path for any route that uses `createLoaderApiRoute` / - // `createActionApiRoute`. - const rawToken = request.headers - .get("Authorization") - ?.replace(/^Bearer /, "") - .trim(); - if (!rawToken) return { ok: false, status: 401, error: "Invalid or Missing API key" }; - - if (options?.allowJWT && isPublicJWT(rawToken)) { - const envId = extractJWTSub(rawToken); - if (!envId) return { ok: false, status: 401, error: "Invalid Public Access Token" }; - - // Match the include shape of the slim AuthenticatedEnvironment so - // the bridge can use the returned env without a follow-up fetch. - const env = await this.replica.runtimeEnvironment.findFirst({ - where: { id: envId }, - include: { - project: true, - organization: true, - orgMember: { - select: { - userId: true, - user: { select: { id: true, displayName: true, name: true } }, - }, - }, - parentEnvironment: { select: { id: true, apiKey: true } }, - }, - }); - if (!env || env.project.deletedAt !== null) { - return { ok: false, status: 401, error: "Invalid Public Access Token" }; - } - - const signingKey = env.parentEnvironment?.apiKey ?? env.apiKey; - const result = await validateJWT(rawToken, signingKey); - if (!result.ok) return { ok: false, status: 401, error: "Public Access Token is invalid" }; - - const scopes = Array.isArray(result.payload.scopes) - ? (result.payload.scopes as string[]) - : []; - const realtime = result.payload.realtime as { skipColumns?: string[] } | undefined; - const oneTimeUse = result.payload.otu === true; - // A JWT minted from a PAT/UAT exchange stamps `act: { sub: userId }` for - // attribution. Surface it so write handlers can record the acting user. - const act = result.payload.act as { sub?: unknown } | undefined; - const actSub = typeof act?.sub === "string" ? act.sub : undefined; - - return { - ok: true, - environment: toAuthenticatedEnvironment(env), - subject: { - type: "publicJWT", - environmentId: env.id, - organizationId: env.organizationId, - projectId: env.projectId, - }, - ability: buildJwtAbility(scopes), - jwt: { realtime, oneTimeUse, ...(actSub ? { act: { sub: actSub } } : {}) }, - }; - } - - // PREVIEW (and DEVELOPMENT) envs are parents — operating "on a branch" means routing - // to a child env keyed by branchName. The customer authenticates - // with the parent's apiKey + an `x-trigger-branch` header. Mirror - // findEnvironmentByApiKey: include the matching child env so the - // pivot below can adopt its identity. - const branchName = sanitizeBranchName(request.headers.get("x-trigger-branch")); - // Match the include shape of the slim AuthenticatedEnvironment so - // the apiBuilder bridge can use the returned env directly without a - // follow-up findEnvironmentById call. - const include = { - project: true, - organization: true, - orgMember: { - select: { - userId: true, - user: { select: { id: true, displayName: true, name: true } }, - }, - }, - parentEnvironment: { select: { id: true, apiKey: true } }, - childEnvironments: branchName ? { where: { branchName, archivedAt: null } } : undefined, - } as const; - let env = await this.replica.runtimeEnvironment.findFirst({ - where: { apiKey: rawToken }, - include, - }); - - // Revoked API key grace window — mirrors `findEnvironmentByApiKey` - // in apps/webapp/app/models/runtimeEnvironment.server.ts. Recently - // rotated keys keep working until their `expiresAt`; without this - // branch a customer who rotates an env API key gets immediate 401s - // on the new auth path. The PR's e2e suite covers this in - // auth-cross-cutting.e2e.full.test.ts ("revoked key within grace"). - if (!env) { - const revoked = await this.replica.revokedApiKey.findFirst({ - where: { apiKey: rawToken, expiresAt: { gt: new Date() } }, - include: { runtimeEnvironment: { include } }, - }); - env = revoked?.runtimeEnvironment ?? null; - } - - if (!env || env.project.deletedAt !== null) { - return { ok: false, status: 401, error: "Invalid API key" }; - } - - if (env.type === "PREVIEW" && !branchName) { - return { - ok: false, - status: 401, - error: "x-trigger-branch header required for preview env", - }; - } - - if (env.type === "PREVIEW" || env.type === "DEVELOPMENT") { - // The "default" root branch is DEVELOPMENT-only: it maps to the dev root env - // (which carries no branch), so we skip the pivot there. For PREVIEW, - // "default" is an ordinary branch name and must still pivot to its child. - const isDevAndDefault = env.type === "DEVELOPMENT" && isDefaultDevBranch(branchName); - if (branchName !== null && !isDevAndDefault) { - const child = env.childEnvironments?.[0]; - if (!child) { - return { ok: false, status: 401, error: "No matching branch env" }; - } - // Pivot to the child env: child's id/type/branchName, parent's - // apiKey/orgMember/organization/project. parentEnvironment is set - // explicitly here so the slim shape stays internally consistent. - env = { - ...child, - apiKey: env.apiKey, - orgMember: env.orgMember, - organization: env.organization, - project: env.project, - parentEnvironment: { id: env.id, apiKey: env.apiKey }, - childEnvironments: [], - }; - } - } - - const subject: RbacSubject = { - type: "user", - userId: env.orgMember?.userId ?? "", - organizationId: env.organizationId, - projectId: env.projectId, - }; - - return { - ok: true, - environment: toAuthenticatedEnvironment(env), - subject, - ability: permissiveAbility, - }; + return this.bearer.authenticate(request, options); } async authenticateSession( @@ -388,14 +232,14 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { taskIdentifiers?: string[]; }) { // Without a plugin there is no preset catalogue, so full access is the only - // policy on offer, but the caller still has to ask for it by name. Any + // policy on offer — but the caller still has to ask for it by name. Any // other preset, or any task selection, is a restricted key and unavailable. if (params.presetId !== FULL_ACCESS_PRESET_ID || (params.taskIdentifiers?.length ?? 0) > 0) { return { ok: false as const, error: "API key access presets are not available" }; } - // `presetId: null` because this install has no catalogue to reference. The - // persisted scopes remain the source of truth for authorization. + // `presetId: null` because this install has no catalogue to reference — the + // key is full-access, not an instance of a named preset. return { ok: true as const, policy: { presetId: null, scopes: ["admin"] }, @@ -463,48 +307,6 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { } } -function isPublicJWT(token: string): boolean { - const parts = token.split("."); - if (parts.length !== 3) return false; - try { - const payload = JSON.parse( - Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8") - ); - return payload !== null && typeof payload === "object" && payload.pub === true; - } catch { - return false; - } -} - -function extractJWTSub(token: string): string | undefined { - const parts = token.split("."); - if (parts.length !== 3) return undefined; - try { - const payload = JSON.parse( - Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8") - ); - return payload !== null && typeof payload === "object" && typeof payload.sub === "string" - ? payload.sub - : undefined; - } catch { - return undefined; - } -} - -// Coerce a Prisma RuntimeEnvironment payload (with project/organization/ -// orgMember/parentEnvironment includes) into the slim AuthenticatedEnvironment -// the auth contract carries. The slim type accepts both `number` and -// Decimal-like for `concurrencyLimitBurstFactor`, but explicit coercion -// here keeps the value a plain number across the auth boundary so -// downstream consumers don't have to narrow before doing arithmetic. -function toAuthenticatedEnvironment(env: RbacEnvironment): RbacEnvironment { - const burst = env.concurrencyLimitBurstFactor; - return { - ...env, - concurrencyLimitBurstFactor: typeof burst === "number" ? burst : burst.toNumber(), - }; -} - function toRbacUser(user: { id: string; email: string; diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index 6873c9d479..c09312363d 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -1,6 +1,7 @@ import type { ApiKeyPolicyDescription, ApiKeyPreset, + BearerAuthResult, Permission, PrepareApiKeyPolicyResult, RbacAbility, @@ -13,8 +14,17 @@ import type { RoleMutationResult, } from "@trigger.dev/plugins"; import type { PrismaClient } from "@trigger.dev/database"; +import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; +import { isPublicJWT } from "@trigger.dev/core/v3/jwt"; + import { RoleBaseAccessFallback } from "./fallback.js"; +import { BearerCredentialResolver, type BearerResolution } from "./bearerCredentials.js"; export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins"; +export type { + BearerCredentialKind, + BearerLookupPath, + BearerResolution, +} from "./bearerCredentials.js"; /** * The controller surface as the HOST sees it, after LazyController has filled in @@ -26,10 +36,18 @@ export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigg * method — so host consumers should depend on this type, not on the plugin * contract, and get a total surface without writing their own guards. */ -export type HostRbacController = Required; +export type HostBearerAuthResult = BearerAuthResult & { resolution: BearerResolution }; + +export type HostRbacController = Omit, "authenticateBearer"> & { + authenticateBearer( + request: Request, + options?: { allowJWT?: boolean } + ): Promise; +}; export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins"; export { buildJwtAbility, scopesWithinAbility } from "./ability.js"; export { FULL_ACCESS_PRESET_ID, scopesGrantFullAccess } from "@trigger.dev/plugins"; +export { resolveJwtSigningKey } from "./bearerCredentials.js"; // Re-export the user-actor token grammar so the webapp mints/checks tokens // through @trigger.dev/rbac (it doesn't import @trigger.dev/plugins directly). export { @@ -56,6 +74,9 @@ export type RbacCreateOptions = { // follows the host's writer/replica topology. The fallback ignores this — // it queries through the Prisma clients passed as `RbacPrismaInput`. database?: RbacDatabaseConfig; + // Synchronous host-owned rollout control. Defaults to enabled for non-webapp + // consumers; the webapp passes its cold-safe global flag reader. + additionalApiKeyLookupEnabled?: () => boolean; }; // Route actions that historically authorised via the legacy checkAuthorization's @@ -85,8 +106,16 @@ export function withActionAliases(underlying: RbacAbility): RbacAbility { // Synchronous create() avoids top-level await (not supported in the webapp's CJS build). class LazyController implements RoleBaseAccessController { private readonly _init: Promise; + // Additional API keys (the ApiKey table) are host-owned, not known to the + // optional plugin. The host resolves them with its always-on credential + // resolver — not a full RBAC fallback controller. + private readonly _hostCredentialResolver: BearerCredentialResolver; constructor(prisma: RbacPrismaInput, options?: RbacCreateOptions) { + this._hostCredentialResolver = new BearerCredentialResolver( + "primary" in prisma ? prisma : { primary: prisma, replica: prisma }, + options?.additionalApiKeyLookupEnabled + ); this._init = this.load(prisma, options); // load() runs eagerly but the result is awaited lazily on first method // call. If load() rejects (e.g. REQUIRE_PLUGINS=1 + plugin missing) and @@ -104,6 +133,7 @@ class LazyController implements RoleBaseAccessController { if (options?.forceFallback) { return new RoleBaseAccessFallback(prisma, { userActorSecret: options?.userActorSecret, + additionalApiKeyLookupEnabled: options?.additionalApiKeyLookupEnabled, }).create(); } const moduleName = "@triggerdotdev/plugins/rbac"; @@ -163,6 +193,7 @@ class LazyController implements RoleBaseAccessController { return new RoleBaseAccessFallback(prisma, { userActorSecret: options?.userActorSecret, + additionalApiKeyLookupEnabled: options?.additionalApiKeyLookupEnabled, }).create(); } } @@ -176,8 +207,55 @@ class LazyController implements RoleBaseAccessController { } async authenticateBearer(...args: Parameters) { - const result = await (await this.c()).authenticateBearer(...args); - return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; + const controller = await this.c(); + const usingPlugin = await controller.isUsingPlugin(); + const [request, options] = args; + const rawToken = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + const useHostForPublicJWT = Boolean(options?.allowJWT && rawToken && isPublicJWT(rawToken)); + const useHostForAdditionalKey = Boolean( + !useHostForPublicJWT && usingPlugin && rawToken && isAdditionalApiKey(rawToken) + ); + + // Public JWT validation and additional environment API keys are host-owned. + // Route those formats directly to the host; all other bearer credentials + // remain authoritative in the installed controller. + const result = + useHostForPublicJWT || useHostForAdditionalKey + ? await this._hostCredentialResolver.authenticate(...args) + : await controller.authenticateBearer(...args); + + const resolution: BearerResolution = + "resolution" in result + ? (result.resolution as BearerResolution) + : useHostForAdditionalKey + ? { credentialKind: "additional_api_key", lookupPath: "additional" } + : useHostForPublicJWT + ? { credentialKind: "public_jwt", lookupPath: "jwt_current" } + : { + credentialKind: rawToken?.startsWith("tr_") ? "root_api_key" : "unknown", + lookupPath: usingPlugin ? "plugin" : "not_found", + }; + + // The format is only a routing hint. A successful host resolution on the + // additional-key path must still produce the expected principal type. + if (useHostForAdditionalKey && result.ok && result.subject.type !== "apiKey") { + return { + ok: false as const, + status: 401 as const, + error: "Invalid API key", + resolution: { + credentialKind: "additional_api_key" as const, + lookupPath: "additional" as const, + }, + }; + } + + return result.ok + ? { ...result, ability: withActionAliases(result.ability), resolution } + : { ...result, resolution }; } async authenticateSession(...args: Parameters) { diff --git a/packages/core/package.json b/packages/core/package.json index f0c3adfcf2..c0ebe06ff8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,6 +31,7 @@ "./v3/build": "./src/v3/build/index.ts", "./v3/apps": "./src/v3/apps/index.ts", "./v3/auth/environment": "./src/v3/auth/environment.ts", + "./v3/apiKeys": "./src/v3/apiKeys.ts", "./v3/jwt": "./src/v3/jwt.ts", "./v3/errors": "./src/v3/errors.ts", "./v3/logger-api": "./src/v3/logger-api.ts", @@ -151,6 +152,9 @@ "v3/jwt": [ "dist/commonjs/v3/jwt.d.ts" ], + "v3/apiKeys": [ + "dist/commonjs/v3/apiKeys.d.ts" + ], "v3/runEngineWorker": [ "dist/commonjs/v3/runEngineWorker/index.d.ts" ], @@ -358,6 +362,17 @@ "default": "./dist/commonjs/v3/auth/environment.js" } }, + "./v3/apiKeys": { + "import": { + "@triggerdotdev/source": "./src/v3/apiKeys.ts", + "types": "./dist/esm/v3/apiKeys.d.ts", + "default": "./dist/esm/v3/apiKeys.js" + }, + "require": { + "types": "./dist/commonjs/v3/apiKeys.d.ts", + "default": "./dist/commonjs/v3/apiKeys.js" + } + }, "./v3/jwt": { "import": { "@triggerdotdev/source": "./src/v3/jwt.ts", diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 3b57395b29..3f6ba0aece 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -430,6 +430,8 @@ export type BatchTriggerTaskV3Response = z.infer { expect(result.success).toBe(true); }); + + it("accepts a non-empty task identifier declaration", () => { + const result = CreateBatchRequestBody.safeParse({ + runCount: 2, + taskIdentifiers: ["task-a", "task-b"], + }); + + expect(result.success).toBe(true); + }); + + it("rejects an empty task identifier declaration", () => { + const result = CreateBatchRequestBody.safeParse({ + runCount: 1, + taskIdentifiers: [], + }); + + expect(result.success).toBe(false); + }); }); describe("CreateWaitpointTokenRequestBody", () => { diff --git a/packages/trigger-sdk/src/v3/shared.test.ts b/packages/trigger-sdk/src/v3/shared.test.ts index 3eb7e821b0..ff5d694edf 100644 --- a/packages/trigger-sdk/src/v3/shared.test.ts +++ b/packages/trigger-sdk/src/v3/shared.test.ts @@ -1,6 +1,22 @@ import { ApiClient } from "@trigger.dev/core/v3"; import { describe, it, expect } from "vitest"; -import { offloadBatchItemPayloads, readableStreamToAsyncIterable } from "./shared.js"; +import { + offloadBatchItemPayloads, + readableStreamToAsyncIterable, + uniqueBatchTaskIdentifiers, +} from "./shared.js"; + +describe("uniqueBatchTaskIdentifiers", () => { + it("returns a stable, deduplicated declaration for batch creation", () => { + expect( + uniqueBatchTaskIdentifiers([ + { index: 0, task: "task-b", payload: "{}" }, + { index: 1, task: "task-a", payload: "{}" }, + { index: 2, task: "task-b", payload: "{}" }, + ]) + ).toEqual(["task-a", "task-b"]); + }); +}); describe("offloadBatchItemPayloads", () => { // A real client is required for conditionallyExportPacket's truthy check; small payloads diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 13d402be6a..25e7396a61 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -1644,6 +1644,10 @@ export async function batchTriggerAndWaitTasks item.task))).sort(); +} + async function executeBatchTwoPhase( apiClient: ReturnType, items: BatchItemNDJSON[], @@ -1678,6 +1682,7 @@ async function executeBatchTwoPhase( batch = await apiClient.createBatch( { runCount: items.length, + taskIdentifiers: uniqueBatchTaskIdentifiers(items), parentRunId: options.parentRunId, resumeParentOnCompletion: options.resumeParentOnCompletion, idempotencyKey: options.idempotencyKey,