diff --git a/.changeset/public-token-expiration-seconds.md b/.changeset/public-token-expiration-seconds.md new file mode 100644 index 00000000000..5eba6ba689b --- /dev/null +++ b/.changeset/public-token-expiration-seconds.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Correct the `expirationTime` docs on `auth.createPublicToken` and the trigger-token helpers: a number is a Unix timestamp in seconds, not milliseconds. diff --git a/.server-changes/api-key-deploy-envvars-presets.md b/.server-changes/api-key-deploy-envvars-presets.md new file mode 100644 index 00000000000..cd7a739e321 --- /dev/null +++ b/.server-changes/api-key-deploy-envvars-presets.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Self-hosted deployments can now create multiple full-access API keys for each environment. diff --git a/.server-changes/public-token-additional-api-keys.md b/.server-changes/public-token-additional-api-keys.md new file mode 100644 index 00000000000..79b7f7d4a19 --- /dev/null +++ b/.server-changes/public-token-additional-api-keys.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Additional environment API keys can now create scoped public access tokens. diff --git a/apps/webapp/app/consts.ts b/apps/webapp/app/consts.ts index e349bc086b6..4bd070a5baf 100644 --- a/apps/webapp/app/consts.ts +++ b/apps/webapp/app/consts.ts @@ -10,6 +10,7 @@ export const RUN_CHUNK_EXECUTION_BUFFER = 350; export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504]; export const MAX_BATCH_TRIGGER_ITEMS = 100; +export const MAX_API_KEY_TASK_IDENTIFIERS = 10; export const MAX_TASK_RUN_ATTEMPTS = 250; export const BULK_ACTION_RUN_LIMIT = 250; export const MAX_JOB_RUN_EXECUTION_COUNT = 250; diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts index 19947417229..c21df2fe650 100644 --- a/apps/webapp/app/models/api-key.server.ts +++ b/apps/webapp/app/models/api-key.server.ts @@ -1,9 +1,22 @@ -import type { RuntimeEnvironment } from "@trigger.dev/database"; -import { prisma } from "~/db.server"; +import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database"; +// HostRbacController, not RoleBaseAccessController: the policy methods are +// optional on the plugin-facing contract, and `rbac` (LazyController) has +// already substituted the fail-closed defaults for any an installed plugin +// omits. Depending on the host surface keeps this call site guard-free. +import type { HostRbacController } from "@trigger.dev/rbac"; +import { trail } from "agentcrumbs"; // @crumbs import { customAlphabet } from "nanoid"; +import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts"; +import { prisma } from "~/db.server"; import { RuntimeEnvironmentType } from "~/database-types"; +import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server"; +import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server"; +import { rbac } from "~/services/rbac.server"; +import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; +const crumb = trail("webapp"); // @crumbs + const apiKeyId = customAlphabet( "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 12 @@ -94,8 +107,178 @@ export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIK return updatedEnviroment; } +export async function createEnvironmentApiKey( + { + environmentId, + taskEnvironmentId, + userId, + name, + expiresAt, + presetId, + taskIdentifiers, + }: { + environmentId: string; + taskEnvironmentId: string; + userId: string; + name: string; + expiresAt?: Date; + // Required, and passed straight through to `prepareApiKeyPolicy` — callers + // name the access level rather than leaning on a default that would grant + // full access. Installs with no preset catalogue pass FULL_ACCESS_PRESET_ID. + presetId: string; + taskIdentifiers?: string[]; + }, + { + prismaClient = prisma, + rbacController = rbac, + issuanceAllowed, + telemetryRecorder = apiKeyTelemetry, + }: { + prismaClient?: Pick< + PrismaClient, + "apiKey" | "featureFlag" | "organization" | "runtimeEnvironment" | "taskIdentifier" + >; + rbacController?: Pick; + issuanceAllowed?: (organizationId: string) => Promise; + telemetryRecorder?: ApiKeyTelemetry; + } = {} +) { + const environment = await prismaClient.runtimeEnvironment.findFirst({ + where: { + id: environmentId, + organization: { members: { some: { userId } } }, + }, + select: { id: true, type: true, organizationId: true }, + }); + + if (!environment) { + throw new Error("Environment not found"); + } + + const canIssue = + issuanceAllowed ?? + ((organizationId) => canIssueAdditionalApiKeys(organizationId, prismaClient)); + if (!(await canIssue(environment.organizationId))) { + throw new Error("Creating additional API keys is not enabled."); + } + + if (expiresAt && expiresAt.getTime() <= Date.now()) { + throw new Error("Expiration must be in the future"); + } + + const selectedTasks = [...new Set(taskIdentifiers?.map((task) => task.trim()).filter(Boolean))]; + + if (selectedTasks.length > MAX_API_KEY_TASK_IDENTIFIERS) { + throw new Error(`You can select at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks for an API key`); + } + if (selectedTasks.length > 0) { + const matchingTasks = await prismaClient.taskIdentifier.count({ + where: { + runtimeEnvironmentId: taskEnvironmentId, + slug: { in: selectedTasks }, + runtimeEnvironment: { + OR: [{ id: environment.id }, { parentEnvironmentId: environment.id }], + }, + }, + }); + + if (matchingTasks !== selectedTasks.length) { + throw new Error("One or more selected tasks are not available in this environment"); + } + } + + let prepared: Awaited>; + try { + prepared = await rbacController.prepareApiKeyPolicy({ + organizationId: environment.organizationId, + presetId, + taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined, + }); + } catch (error) { + telemetryRecorder.recordOperation("prepare_policy", "error", "policy_error"); + throw error; + } + + if (!prepared.ok) { + telemetryRecorder.recordOperation("prepare_policy", "rejected", "policy_rejected"); + throw new Error(prepared.error); + } + telemetryRecorder.recordOperation("prepare_policy", "success"); + + const generated = generateAdditionalApiKey(environment.type); + const apiKey = await (async () => { + try { + return await prismaClient.apiKey.create({ + data: { + name, + keyHash: generated.keyHash, + lastFour: generated.lastFour, + runtimeEnvironmentId: environment.id, + createdByUserId: userId, + expiresAt, + presetId: prepared.policy.presetId, + scopes: prepared.policy.scopes, + }, + }); + } catch (error) { + telemetryRecorder.recordOperation("create", "error", "database_error"); + throw error; + } + })(); + telemetryRecorder.recordOperation("create", "success"); + + crumb("environment API key created", { + apiKeyId: apiKey.id, + environmentId, + presetId: apiKey.presetId, + }); // @crumbs + + return { apiKey, plaintext: generated.apiKey }; +} + +export async function revokeEnvironmentApiKey( + { + environmentId, + apiKeyId, + }: { + environmentId: string; + apiKeyId: string; + }, + { + prismaClient = prisma, + telemetryRecorder = apiKeyTelemetry, + }: { + prismaClient?: Pick; + telemetryRecorder?: ApiKeyTelemetry; + } = {} +) { + const result = await (async () => { + try { + return await prismaClient.apiKey.updateMany({ + where: { + id: apiKeyId, + runtimeEnvironmentId: environmentId, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + } catch (error) { + telemetryRecorder.recordOperation("revoke", "error", "database_error"); + throw error; + } + })(); + + if (result.count !== 1) { + telemetryRecorder.recordOperation("revoke", "rejected", "not_found_or_revoked"); + throw new Error("API key not found or already revoked"); + } + + telemetryRecorder.recordOperation("revoke", "success"); + crumb("environment API key revoked", { apiKeyId, environmentId }); // @crumbs +} + export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) { - return `tr_${envSlug(envType)}_${apiKeyId(20)}`; + return generateRootApiKey(envType).apiKey; } export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) { diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 939cd55e94d..84d2979bb10 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/ApiKeysPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts index 846d2790322..ce562edfd06 100644 --- a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts @@ -1,67 +1,64 @@ import { type RuntimeEnvironment } from "@trigger.dev/database"; -import { type PrismaClient, prisma } from "~/db.server"; +// HostRbacController, not RoleBaseAccessController: the policy methods are +// optional on the plugin-facing contract, and `rbac` (LazyController) has +// already substituted the fail-closed defaults for any an installed plugin +// omits. Depending on the host surface keeps these call sites guard-free. +import { scopesGrantFullAccess, type HostRbacController } from "@trigger.dev/rbac"; +import { type PrismaReplicaClient, $replica } from "~/db.server"; import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; +import { rbac } from "~/services/rbac.server"; +import { obfuscateApiKey } from "~/utils/apiKeys"; + +type ApiKeyPolicyPresenter = Pick; export class ApiKeysPresenter { - #prismaClient: PrismaClient; + // Read-only presenter for a dashboard page — all queries below are reads, so + // default to the replica and keep this off the writer. + #prismaClient: PrismaReplicaClient; + #rbac: ApiKeyPolicyPresenter; - constructor(prismaClient: PrismaClient = prisma) { + constructor( + prismaClient: PrismaReplicaClient = $replica, + rbacController: ApiKeyPolicyPresenter = rbac + ) { this.#prismaClient = prismaClient; + this.#rbac = rbacController; } public async call({ userId, + organizationSlug, projectSlug, environmentSlug, + showRevoked = false, }: { userId: User["id"]; + organizationSlug: string; projectSlug: Project["slug"]; environmentSlug: RuntimeEnvironment["slug"]; + showRevoked?: boolean; }) { const environment = await this.#prismaClient.runtimeEnvironment.findFirst({ select: { id: true, - apiKey: true, type: true, slug: true, - updatedAt: true, - orgMember: { - select: { - userId: true, - }, - }, branchName: true, - parentEnvironment: { - select: { - id: true, - apiKey: true, - }, - }, - project: { - select: { - id: true, - }, + parentEnvironmentId: true, + taskIdentifiers: { + where: { isInLatestDeployment: true }, + orderBy: { slug: "asc" }, + select: { slug: true }, }, + project: { select: { id: true } }, + organizationId: true, }, where: { - project: { - slug: projectSlug, - }, - organization: { - members: { - some: { - userId, - }, - }, - }, + project: { slug: projectSlug, organization: { slug: organizationSlug } }, + organization: { slug: organizationSlug, members: { some: { userId } } }, slug: environmentSlug, - orgMember: - environmentSlug === "dev" - ? { - userId, - } - : undefined, + OR: [{ type: { not: "DEVELOPMENT" } }, { type: "DEVELOPMENT", orgMember: { userId } }], }, }); @@ -69,20 +66,98 @@ export class ApiKeysPresenter { throw new Error("Environment not found"); } - const vercelIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({ - where: { - projectId: environment.project.id, - deletedAt: null, - organizationIntegration: { service: "VERCEL", deletedAt: null }, - }, - select: { id: true }, - }); + const keyEnvironmentId = environment.parentEnvironmentId ?? environment.id; + + const [keyEnvironment, vercelIntegration] = await Promise.all([ + this.#prismaClient.runtimeEnvironment.findUniqueOrThrow({ + where: { id: keyEnvironmentId }, + select: { + id: true, + apiKey: true, + type: true, + createdAt: true, + apiKeys: { + where: showRevoked ? undefined : { revokedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + name: true, + lastFour: true, + presetId: true, + scopes: true, + lastUsedAt: true, + revokedAt: true, + expiresAt: true, + createdAt: true, + createdBy: { + select: { + id: true, + email: true, + name: true, + displayName: true, + }, + }, + }, + }, + }, + }), + this.#prismaClient.organizationProjectIntegration.findFirst({ + where: { + projectId: environment.project.id, + deletedAt: null, + organizationIntegration: { service: "VERCEL", deletedAt: null }, + }, + select: { id: true }, + }), + ]); + + const [presets, policyDescriptions] = await Promise.all([ + this.#rbac.apiKeyPresets(environment.organizationId), + Promise.all( + keyEnvironment.apiKeys.map((apiKey) => + this.#rbac.describeApiKeyPolicy({ + presetId: apiKey.presetId, + scopes: apiKey.scopes, + }) + ) + ), + ]); + const presetsById = new Map(presets?.map((preset) => [preset.id, preset])); + const { taskIdentifiers, organizationId: _organizationId, ...environmentData } = environment; return { environment: { - ...environment, - apiKey: environment?.parentEnvironment?.apiKey ?? environment?.apiKey, + ...environmentData, + apiKey: keyEnvironment.apiKey, + keyEnvironmentId, + }, + availableTasks: taskIdentifiers.map((task) => task.slug), + rootApiKey: { + id: keyEnvironment.id, + name: "Root API key", + value: keyEnvironment.apiKey, + obfuscated: obfuscateApiKey(keyEnvironment.type, keyEnvironment.apiKey.slice(-4)), + createdAt: keyEnvironment.createdAt, }, + apiKeys: keyEnvironment.apiKeys.map((apiKey, index) => { + const { presetId, scopes, ...apiKeyData } = apiKey; + const description = policyDescriptions[index]; + const preset = presetId ? presetsById.get(presetId) : undefined; + const isFullAccess = scopesGrantFullAccess(scopes); + + return { + ...apiKeyData, + access: { + presetId, + label: preset?.label ?? (presetId === null && isFullAccess ? "Full access" : "Custom"), + taskIdentifiers: description.taskIdentifiers, + usesTaskSelection: + preset?.usesTaskSelection ?? description.taskIdentifiers !== undefined, + }, + obfuscated: obfuscateApiKey(keyEnvironment.type, apiKey.lastFour, "additional"), + }; + }), + presets, hasVercelIntegration: vercelIntegration !== null, }; } diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index c6a14a44921..392c639b539 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/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index 1d6b12bd2d0..f3e9e0cfe7b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -1,16 +1,26 @@ -import { BookOpenIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; +import { + BookOpenIcon, + CheckCircleIcon, + ExclamationTriangleIcon, + KeyIcon, + NoSymbolIcon, + PlusIcon, +} from "@heroicons/react/20/solid"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { type MetaFunction, Form, useFetcher, useSearchParams } from "@remix-run/react"; +import { useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { CopyableText } from "~/components/primitives/CopyableText"; import { CodeBlock } from "~/components/code/CodeBlock"; import { InlineCode } from "~/components/code/InlineCode"; +import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal"; import { EnvironmentCombo, environmentFullTitle, environmentTextClassName, } from "~/components/environments/EnvironmentLabel"; -import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal"; import { MainHorizontallyCenteredContainer, PageBody, @@ -23,61 +33,195 @@ import { AccordionItem, AccordionTrigger, } from "~/components/primitives/Accordion"; -import { LinkButton } from "~/components/primitives/Buttons"; +import { Badge } from "~/components/primitives/Badge"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { CopyButton } from "~/components/primitives/CopyButton"; +import { DateTime } from "~/components/primitives/DateTime"; +import { DateTimePicker } from "~/components/primitives/DateTimePicker"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; import { Header2 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { useOrganization } from "~/hooks/useOrganizations"; +import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; +import { Switch } from "~/components/primitives/Switch"; +import { + Table, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts"; +import { createEnvironmentApiKey, revokeEnvironmentApiKey } from "~/models/api-key.server"; +import { + redirectWithErrorMessage, + redirectWithSuccessMessage, + typedJsonWithErrorMessage, + typedJsonWithSuccessMessage, +} from "~/models/message.server"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; -import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { FULL_ACCESS_PRESET_ID } from "@trigger.dev/rbac"; +import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server"; +import { rbac } from "~/services/rbac.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; -export const meta: MetaFunction = () => { - return [ - { - title: `API keys | Trigger.dev`, - }, - ]; -}; +export const meta: MetaFunction = () => [{ title: "API keys | Trigger.dev" }]; + +const ApiKeySearchParams = z.object({ + showRevoked: z.preprocess((value) => value === "true" || value === true, z.boolean()).optional(), +}); + +type ApiKeyPreset = NonNullable>>[number]; + +const CreateApiKeySchema = z.object({ + action: z.literal("create"), + name: z.string().trim().min(1).max(64), + expiresAt: z.preprocess( + (value) => (value === "" || value === undefined ? undefined : value), + z.coerce + .date() + .refine((date) => date.getTime() > Date.now(), "Expiration must be in the future") + .optional() + ), + presetId: z.string().trim().min(1).optional(), + taskScope: z.enum(["all", "selected"]).optional(), + taskIdentifiers: z + .array(z.string()) + .max(MAX_API_KEY_TASK_IDENTIFIERS, { + message: `You can select at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks`, + }) + .default([]), +}); + +const ApiKeyActionSchema = z.discriminatedUnion("action", [ + CreateApiKeySchema, + z.object({ action: z.literal("revoke"), apiKeyId: z.string().min(1) }), +]); + +function validateCreateApiKeyPreset({ + presets, + presetId, + taskScope, + taskIdentifiers, + hasTaskParameters, +}: { + presets: ApiKeyPreset[] | null; + presetId?: string; + taskScope?: "all" | "selected"; + taskIdentifiers: string[]; + hasTaskParameters: boolean; +}): { presetId: string; usesTaskSelection: boolean } { + // Always resolves to a concrete preset id. "No preset chosen" means full + // access, and saying so here keeps that decision visible at the call site + // instead of relying on a default inside prepareApiKeyPolicy. + const fullAccess = { presetId: FULL_ACCESS_PRESET_ID, usesTaskSelection: false }; + + if (presets === null) { + if (presetId !== undefined || hasTaskParameters) { + throw new Error("API key access presets are not available"); + } + return fullAccess; + } + + if (!presetId) { + if (hasTaskParameters) { + throw new Error("A preset is required when selecting tasks"); + } + return fullAccess; + } + + const preset = presets.find((candidate) => candidate.id === presetId); + if (!preset) { + throw new Error("Invalid API key access preset"); + } + if (!preset.available) { + throw new Error("This API key access preset is not available on your plan"); + } + + if (!preset.usesTaskSelection && hasTaskParameters) { + throw new Error("This API key access preset does not support task selection"); + } + if (preset.usesTaskSelection && taskScope === "selected" && taskIdentifiers.length === 0) { + throw new Error("Select at least one task"); + } + if (preset.usesTaskSelection && taskScope !== "selected" && taskIdentifiers.length > 0) { + throw new Error("Task identifiers require selected task scope"); + } + + return { presetId: preset.id, usesTaskSelection: preset.usesTaskSelection ?? false }; +} + +type ApiKeyActionData = + | { ok: true; action: "create"; apiKey: string } + | { ok: false; error: string }; export const loader = dashboardLoader( { params: EnvironmentParamSchema, + searchParams: ApiKeySearchParams, context: async (params) => { const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, - // No hard authorization: anyone with project access can open the page. - // Reading the secret key is gated per environment tier below — a role - // that can't read this tier's keys gets the info panel, not the key. }, - async ({ params, user, ability }) => { - const { projectParam, envParam } = params; - + async ({ params, searchParams, user, ability, context }) => { try { const presenter = new ApiKeysPresenter(); - const { environment, hasVercelIntegration } = await presenter.call({ - userId: user.id, - projectSlug: projectParam, - environmentSlug: envParam, - }); + const [data, additionalApiKeyIssuanceEnabled] = await Promise.all([ + presenter.call({ + userId: user.id, + organizationSlug: params.organizationSlug, + projectSlug: params.projectParam, + environmentSlug: params.envParam, + showRevoked: searchParams.showRevoked, + }), + context.organizationId + ? canIssueAdditionalApiKeys(context.organizationId) + : Promise.resolve(false), + ]); - const canReadApiKeys = - !environment || ability.can("read", { type: "apiKeys", envType: environment.type }); + const canReadApiKeys = ability.can("read", { + type: "apiKeys", + envType: data.environment.type, + }); + const canWriteApiKeys = ability.can("write", { + type: "apiKeys", + envType: data.environment.type, + }); return typedjson({ - // Never serialize the secret key to the client when the role can't - // read it for this environment tier. - environment: environment && !canReadApiKeys ? { ...environment, apiKey: "" } : environment, - hasVercelIntegration, + ...data, + environment: { + ...data.environment, + apiKey: canReadApiKeys ? data.environment.apiKey : null, + }, + rootApiKey: { + ...data.rootApiKey, + value: canReadApiKeys ? data.rootApiKey.value : null, + obfuscated: canReadApiKeys ? data.rootApiKey.obfuscated : null, + }, + apiKeys: canReadApiKeys ? data.apiKeys : [], canReadApiKeys, + canWriteApiKeys, + additionalApiKeyIssuanceEnabled, + showRevoked: searchParams.showRevoked ?? false, }); } catch (error) { console.error(error); @@ -89,21 +233,139 @@ export const loader = dashboardLoader( } ); -export default function Page() { - const { environment, hasVercelIntegration, canReadApiKeys } = useTypedLoaderData(); - const _organization = useOrganization(); +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); + return organizationId ? { organizationId } : {}; + }, + // The environment tier is only known after resolving the route params, + // so write:apiKeys is enforced in the handler before any mutation. + }, + async ({ request, params, user, ability }) => { + if (request.method.toUpperCase() !== "POST") { + throw new Response("Method Not Allowed", { status: 405 }); + } + + const project = await findProjectBySlug(params.organizationSlug, params.projectParam, user.id); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, params.envParam, user.id); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + if (!ability.can("write", { type: "apiKeys", envType: environment.type })) { + return typedJsonWithErrorMessage( + { ok: false as const, error: "You don't have permission to manage these API keys." }, + request, + "You don't have permission to manage these API keys." + ); + } - if (!environment) { - throw new Response(undefined, { - status: 404, - statusText: "Environment not found", + const formData = await request.formData(); + const hasTaskParameters = formData.has("taskScope") || formData.has("taskIdentifiers"); + const submission = ApiKeyActionSchema.safeParse({ + ...Object.fromEntries(formData), + taskIdentifiers: formData.getAll("taskIdentifiers"), }); - } + if (!submission.success) { + const error = submission.error.issues[0]?.message ?? "Invalid API key request"; + return typedJsonWithErrorMessage({ ok: false as const, error }, request, error); + } + + const keyEnvironmentId = environment.parentEnvironmentId ?? environment.id; + const returnPath = `${new URL(request.url).pathname}${new URL(request.url).search}`; - let envBlock = `TRIGGER_SECRET_KEY="${environment.apiKey}"`; - if (environment.branchName) { - envBlock += `\nTRIGGER_PREVIEW_BRANCH="${environment.branchName}"`; + try { + switch (submission.data.action) { + case "create": { + if (!(await canIssueAdditionalApiKeys(project.organizationId))) { + const message = "Creating additional API keys is not enabled."; + return typedJsonWithErrorMessage( + { ok: false as const, error: message }, + request, + message + ); + } + + const presets = await rbac.apiKeyPresets(project.organizationId); + const preset = validateCreateApiKeyPreset({ + presets, + presetId: submission.data.presetId, + taskScope: submission.data.taskScope, + taskIdentifiers: submission.data.taskIdentifiers, + hasTaskParameters, + }); + const result = await createEnvironmentApiKey({ + environmentId: keyEnvironmentId, + taskEnvironmentId: environment.id, + userId: user.id, + name: submission.data.name, + expiresAt: submission.data.expiresAt, + presetId: preset.presetId, + taskIdentifiers: + preset.usesTaskSelection && submission.data.taskScope === "selected" + ? submission.data.taskIdentifiers + : undefined, + }); + + return typedJsonWithSuccessMessage( + { + ok: true as const, + action: "create" as const, + apiKey: result.plaintext, + }, + request, + `Created ${submission.data.name} API key` + ); + } + case "revoke": { + await revokeEnvironmentApiKey({ + environmentId: keyEnvironmentId, + apiKeyId: submission.data.apiKeyId, + }); + + return redirectWithSuccessMessage(returnPath, request, "API key revoked"); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to update API keys"; + + if (submission.data.action === "create") { + return typedJsonWithErrorMessage({ ok: false as const, error: message }, request, message); + } + + return redirectWithErrorMessage(returnPath, request, message); + } } +); + +export default function Page() { + const { + environment, + rootApiKey, + apiKeys, + canReadApiKeys, + canWriteApiKeys, + additionalApiKeyIssuanceEnabled, + showRevoked, + hasVercelIntegration, + availableTasks, + presets, + } = useTypedLoaderData(); + + const envBlock = environment.apiKey + ? [ + `TRIGGER_SECRET_KEY="${environment.apiKey}"`, + environment.branchName ? `TRIGGER_PREVIEW_BRANCH="${environment.branchName}"` : null, + ] + .filter(Boolean) + .join("\n") + : null; return ( @@ -112,7 +374,7 @@ export default function Page() { - + {environment.slug} @@ -121,106 +383,553 @@ export default function Page() { - + API keys docs + + {canReadApiKeys && additionalApiKeyIssuanceEnabled ? ( + + ) : null} - - -
- - - API keys - -
- {canReadApiKeys ? ( -
- -
- - + {canReadApiKeys ? ( +
+ +
+ + -
- - - Set this as your TRIGGER_SECRET_KEY{" "} - env var in your backend. - - - {environment.branchName && ( - - - - - Set this as your{" "} - TRIGGER_PREVIEW_BRANCH env var in - your backend. - - - )} - {environment.type === "DEVELOPMENT" && ( - - Every team member gets their own dev Secret key. Make sure you're using the one - above otherwise you will trigger runs on your team member's machine. + API keys + +
+ + {environment.type === "DEVELOPMENT" ? ( + + Every team member gets their own dev API keys. Make sure you're using one from + this page, otherwise you will trigger runs on your team member's machine. - )} + ) : null} - + How to set these environment variables
- You need to set these environment variables in your backend. This allows the - SDK to authenticate with Trigger.dev. + Set these environment variables in your backend so the SDK can authenticate + with Trigger.dev.
- + {envBlock ? ( + + ) : null}
+ + +
+
- ) : ( + +
+ + + + Name + Secret key + Status + Access + Created by + Created + Last used + Actions + + + + + +
+ + {rootApiKey.name} + Root +
+
+ +
+ + {rootApiKey.obfuscated ?? "–"} + + {rootApiKey.value ? ( + + ) : null} +
+
+ + + + + + + + + + + + + ) : null + } + /> +
+ + {apiKeys.map((apiKey) => { + const isExpired = apiKey.expiresAt + ? new Date(apiKey.expiresAt).getTime() <= Date.now() + : false; + const cannotAuthenticate = Boolean(apiKey.revokedAt) || isExpired; + const cannotRevoke = Boolean(apiKey.revokedAt) || isExpired; + const creator = + apiKey.createdBy?.displayName ?? + apiKey.createdBy?.name ?? + apiKey.createdBy?.email ?? + "–"; + + return ( + + {apiKey.name} + + {apiKey.obfuscated} + + + + + + + + {creator} + + + + + {apiKey.lastUsedAt ? : "Never"} + + + ) + } + /> + + ); + })} +
+
+
+
+ ) : ( + - )} - + + )} ); } + +function RevokedFilter({ checked }: { checked: boolean }) { + const [, setSearchParams] = useSearchParams(); + + return ( + { + setSearchParams((searchParams) => { + if (showRevoked) { + searchParams.set("showRevoked", "true"); + } else { + searchParams.delete("showRevoked"); + } + return searchParams; + }); + }} + label="Show revoked" + variant="secondary/small" + /> + ); +} + +function NewApiKeyDialog({ + canWrite, + availableTasks, + presets, +}: { + canWrite: boolean; + availableTasks: string[]; + presets: ApiKeyPreset[] | null; +}) { + const fetcher = useFetcher(); + const actionData = fetcher.data as ApiKeyActionData | undefined; + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [expiresAt, setExpiresAt] = useState(); + const defaultPresetId = presets?.find((preset) => preset.available)?.id ?? ""; + const [presetId, setPresetId] = useState(defaultPresetId); + const [taskScope, setTaskScope] = useState<"all" | "selected">("all"); + const [selectedTasks, setSelectedTasks] = useState([]); + const [createdApiKey, setCreatedApiKey] = useState(); + + useEffect(() => { + if (fetcher.state === "idle" && actionData?.ok && actionData.action === "create") { + setCreatedApiKey(actionData.apiKey); + } + }, [actionData, fetcher.state]); + + const usesTaskSelection = + presets?.find((preset) => preset.id === presetId)?.usesTaskSelection ?? false; + const needsSelectedTask = usesTaskSelection && taskScope === "selected"; + + return ( + { + setOpen(nextOpen); + if (nextOpen) { + setName(""); + setExpiresAt(undefined); + setPresetId(defaultPresetId); + setTaskScope("all"); + setSelectedTasks([]); + setCreatedApiKey(undefined); + } + }} + > + + + + + New API key + {createdApiKey ? ( +
+ + Copy this API key and store it in a secure place. You won't be able to see it again. + + + + Use @trigger.dev/sdk v4.5.8 or later. + Older SDK versions mint an unusable token when{" "} + auth.createPublicToken() is called with + this API key. + + + + + } + /> +
+ ) : ( + + + {expiresAt ? ( + + ) : null} + {presetId ? : null} + {usesTaskSelection ? : null} +
+ + + setName(event.target.value)} + placeholder="e.g. Stripe webhooks" + maxLength={64} + autoComplete="off" + fullWidth + /> + Use a name that identifies where this key will be used. + + + + + + Leave blank for a key that doesn't expire. + + + {presets ? ( + + + + {presets.map((preset) => ( + + ))} + + + ) : null} + + {usesTaskSelection ? ( + + + setTaskScope(value as "all" | "selected")} + className="grid gap-2" + > + + + + + {taskScope === "selected" ? ( +
+ {availableTasks.map((taskIdentifier) => ( + {taskIdentifier}} + defaultChecked={selectedTasks.includes(taskIdentifier)} + onChange={(checked) => { + setSelectedTasks((current) => + checked + ? [...new Set([...current, taskIdentifier])] + : current.filter((task) => task !== taskIdentifier) + ); + }} + /> + ))} +
+ ) : null} + + Task restrictions use task identifiers and continue to apply across deployments. + +
+ ) : null} + + {actionData && !actionData.ok ? ( + + {actionData.error} + + ) : null} + + MAX_API_KEY_TASK_IDENTIFIERS)) || + fetcher.state !== "idle" + } + isLoading={fetcher.state !== "idle"} + > + Create API key + + } + cancelButton={ + + + + } + /> +
+
+ )} +
+
+ ); +} + +function RevokeApiKeyButton({ + id, + name, + canWrite, +}: { + id: string; + name: string; + canWrite: boolean; +}) { + return ( + + + + + + Revoke API key +
+ + Are you sure you want to revoke "{name}"? Requests using this key will stop + authenticating, and it won't be able to mint new public tokens. Public tokens it already + minted remain valid until they expire. This can't be reversed. + + + + + + + } + cancelButton={ + + + + } + /> +
+
+
+ ); +} + +function ApiKeyAccess({ + label, + taskIdentifiers, + usesTaskSelection = false, +}: { + label: string; + taskIdentifiers?: string[]; + usesTaskSelection?: boolean; +}) { + return ( +
+ {label} + {usesTaskSelection ? ( + + {taskIdentifiers === undefined + ? "All tasks" + : `${taskIdentifiers.length} selected ${taskIdentifiers.length === 1 ? "task" : "tasks"}`} + + ) : null} +
+ ); +} + +function ApiKeyStatus({ + revokedAt, + expiresAt, +}: { + revokedAt?: Date | string | null; + expiresAt?: Date | string | null; +}) { + if (revokedAt) { + return ( +
+ + Revoked +
+ ); + } + + if (expiresAt && new Date(expiresAt).getTime() <= Date.now()) { + return ( +
+ + Expired +
+ ); + } + + return ( +
+ + Active +
+ ); +} diff --git a/apps/webapp/app/routes/api.v1.artifacts.ts b/apps/webapp/app/routes/api.v1.artifacts.ts index c74c66a222d..a706f9e04ef 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.auth.public-tokens.ts b/apps/webapp/app/routes/api.v1.auth.public-tokens.ts new file mode 100644 index 00000000000..08def65fd71 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.auth.public-tokens.ts @@ -0,0 +1,6 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { handlePublicTokenRequest } from "~/services/publicTokens.server"; + +export async function action({ request }: ActionFunctionArgs) { + return handlePublicTokenRequest(request); +} 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 9e7052b7275..f0d419ca183 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 d14f7a8bc22..168de476758 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 7ba5dd3700d..df596716a8f 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 f576f6eef38..7b9c5419491 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 78488bfeeb5..fe60b9ace45 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 671f42606a2..839e5a5cf5b 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 dd16fabf24c..6b7accd0291 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 f2c69078b9e..c7cce0d1ed3 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 7609f874255..9354db4de33 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 39988e1d13d..273259d87df 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 42ef412ec19..5c01c375601 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 4e0fca9a480..49ce15ae188 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 42b225f4e17..0f14d5b0f44 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 49990204c1e..97b4795c25e 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 151c182e166..07945df9ba9 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 3296e7fa782..aa31e6aba5e 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 9a83f3ed845..633324e86dd 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 a0f910fe58f..6c3c1f25dc2 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 a9bcd2342e5..2f72805f62c 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 b257a248a0b..27fe2eb93db 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 df684946b18..f0bf949e351 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 a4f543e68f5..dca246a0c24 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 1a10e6c6f02..c18412c4810 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 e57092bc66b..2bd7bc56508 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 9d1e4c5b8dd..61650493303 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 a43e0d3af53..5c9202d6fe5 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 ace6128ac7e..62322c527c7 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 0fece044fb6..b7e6bdc2f27 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 cb660049c9c..18a5d2cc30f 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 c766bb474f6..5dcbf13e0fb 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 c34dbc178c4..9e2bd510256 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 3179e2fa6aa..4e2c4b4b33b 100644 --- a/apps/webapp/app/routes/api.v3.batches.ts +++ b/apps/webapp/app/routes/api.v3.batches.ts @@ -5,11 +5,14 @@ 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 } from "~/services/routeBuilders/apiBuilder.server"; +import { batchPublicAccessScopes } from "~/utils/batchItemAuthorization"; +import { canWriteParentRun } from "~/utils/parentRunAuthorization.server"; import { clientSafeErrorMessage } from "~/utils/prismaErrors"; import { handleRequestIdempotency, @@ -37,18 +40,29 @@ 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" }), + // No specific tasks exist yet. This grant only creates the batch shell; + // phase two authorizes every streamed task before enqueueing it. + resource: () => anyResource([{ 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( @@ -99,7 +113,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 + ); }, }); @@ -132,6 +151,7 @@ const { action, loader } = createActionApiRoute( const $responseHeaders = await responseHeaders( batch, authentication.environment, + ability, triggerClient ); @@ -198,34 +218,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, - }); - - if (triggerClient === "browser") { - const claims = { + // 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"); + + 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 87fff8bbf76..2fdcc0de21e 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 5debce70356..0e7cecbbe07 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/additionalApiKeyIssuance.server.ts b/apps/webapp/app/services/additionalApiKeyIssuance.server.ts new file mode 100644 index 00000000000..c5cf56bf6d7 --- /dev/null +++ b/apps/webapp/app/services/additionalApiKeyIssuance.server.ts @@ -0,0 +1,35 @@ +import { type PrismaClient } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { resolveAdditionalApiKeyIssuance } from "~/services/additionalApiKeyIssuance"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; + +type IssuancePrismaClient = Pick; + +export async function canIssueAdditionalApiKeys( + organizationId: string, + prismaClient: IssuancePrismaClient = prisma +): Promise { + const [organization, globalFlags] = await Promise.all([ + prismaClient.organization.findUnique({ + where: { id: organizationId }, + select: { featureFlags: true }, + }), + prismaClient.featureFlag.findMany({ + where: { + key: { + in: [FEATURE_FLAG.additionalApiKeysEnabled, FEATURE_FLAG.additionalApiKeyIssuanceEnabled], + }, + }, + select: { key: true, value: true }, + }), + ]); + + if (!organization) { + return false; + } + + return resolveAdditionalApiKeyIssuance( + Object.fromEntries(globalFlags.map((featureFlag) => [featureFlag.key, featureFlag.value])), + (organization.featureFlags as Record | null) ?? undefined + ); +} diff --git a/apps/webapp/app/services/additionalApiKeyIssuance.ts b/apps/webapp/app/services/additionalApiKeyIssuance.ts new file mode 100644 index 00000000000..380a1468751 --- /dev/null +++ b/apps/webapp/app/services/additionalApiKeyIssuance.ts @@ -0,0 +1,17 @@ +import { FEATURE_FLAG, type FeatureFlagCatalog } from "~/v3/featureFlags"; + +export function resolveAdditionalApiKeyIssuance( + globalFlags: Partial | Record | undefined, + organizationFlags: Record | undefined +): boolean { + if (globalFlags?.[FEATURE_FLAG.additionalApiKeyIssuanceEnabled] !== true) { + return false; + } + + const organizationOverride = organizationFlags?.[FEATURE_FLAG.additionalApiKeysEnabled]; + if (organizationOverride === true || organizationOverride === false) { + return organizationOverride; + } + + return globalFlags?.[FEATURE_FLAG.additionalApiKeysEnabled] === true; +} diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 899b693a98e..f485a6bb70b 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/apiKeyTelemetry.server.ts b/apps/webapp/app/services/apiKeyTelemetry.server.ts new file mode 100644 index 00000000000..3d4b80fe5a1 --- /dev/null +++ b/apps/webapp/app/services/apiKeyTelemetry.server.ts @@ -0,0 +1,49 @@ +import { getMeter } from "@internal/tracing"; +import { singleton } from "~/utils/singleton"; + +export type ApiKeyOperation = "create" | "prepare_policy" | "revoke"; +export type ApiKeyOperationResult = "success" | "rejected" | "error"; +export type ApiKeyOperationReason = + | "none" + | "database_error" + | "not_found_or_revoked" + | "policy_rejected" + | "policy_error"; + +export type PublicTokenMintResult = "success" | "rejected" | "error"; +export type PublicTokenMintReason = + | "none" + | "invalid_body" + | "scope_not_allowed" + | "invalid_expiration" + | "expiration_not_future" + | "expiration_too_long" + | "signing_failed"; + +const telemetry = singleton("apiKeyTelemetry", () => { + const meter = getMeter("api-key"); + + return { + operations: meter.createCounter("api_key.operations", { + description: "Additional environment API key management operations", + }), + publicTokenMintAttempts: meter.createCounter("public_token.mint_attempts", { + description: "Public access token mint attempts using environment API keys", + }), + }; +}); + +export const apiKeyTelemetry = { + recordOperation( + operation: ApiKeyOperation, + result: ApiKeyOperationResult, + reason: ApiKeyOperationReason = "none" + ) { + telemetry.operations.add(1, { operation, result, reason }); + }, + recordPublicTokenMint(result: PublicTokenMintResult, reason: PublicTokenMintReason = "none") { + telemetry.publicTokenMintAttempts.add(1, { result, reason }); + }, +}; + +export type ApiKeyTelemetry = typeof apiKeyTelemetry; diff --git a/apps/webapp/app/services/authFeatureControls.server.ts b/apps/webapp/app/services/authFeatureControls.server.ts new file mode 100644 index 00000000000..5052b6b5a3c --- /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 00000000000..1707dee37fb --- /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 00000000000..9a229c70f3e --- /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 11d401125ac..e9fe2ef781c 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 00000000000..f4f20bcf7d8 --- /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/publicTokens.server.ts b/apps/webapp/app/services/publicTokens.server.ts new file mode 100644 index 00000000000..590a7f3b2c9 --- /dev/null +++ b/apps/webapp/app/services/publicTokens.server.ts @@ -0,0 +1,138 @@ +import { generateJWT } from "@trigger.dev/core/v3/jwt"; +import type { RoleBaseAccessController } from "@trigger.dev/rbac"; +import { resolveJwtSigningKey, scopesWithinAbility } from "@trigger.dev/rbac"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server"; +import { rbac } from "~/services/rbac.server"; + +// Public access tokens may be valid for at most 30 days. +export const MAX_PUBLIC_TOKEN_LIFETIME_SECONDS = 30 * 24 * 60 * 60; + +const RequestBodySchema = z.object({ + scopes: z.array(z.string()).min(1), + expirationTime: z.union([z.string(), z.number()]).optional(), + oneTimeUse: z.boolean().optional(), + realtime: z + .object({ + skipColumns: z.array(z.string()).optional(), + }) + .optional(), +}); + +const RELATIVE_TIME_PATTERN = + /^(\+|-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i; + +function expirationTimestamp(expirationTime: string | number, now: number): number | undefined { + if (typeof expirationTime === "number") { + return expirationTime; + } + + const match = RELATIVE_TIME_PATTERN.exec(expirationTime); + if (!match || (match[4] && match[1])) { + return undefined; + } + + const value = Number.parseFloat(match[2]!); + const unit = match[3]!.toLowerCase(); + const unitSeconds = unit.startsWith("s") + ? 1 + : unit.startsWith("m") + ? 60 + : unit.startsWith("h") + ? 60 * 60 + : unit.startsWith("d") + ? 24 * 60 * 60 + : unit.startsWith("w") + ? 7 * 24 * 60 * 60 + : 365.25 * 24 * 60 * 60; + const relativeSeconds = Math.round(value * unitSeconds); + const isPast = match[1] === "-" || match[4]?.toLowerCase() === "ago"; + + return now + (isPast ? -relativeSeconds : relativeSeconds); +} + +export async function handlePublicTokenRequest( + request: Request, + controller: Pick = rbac, + telemetryRecorder: ApiKeyTelemetry = apiKeyTelemetry +) { + // Public JWTs are intentionally not enabled here. Only API keys may mint tokens. + const authResult = await controller.authenticateBearer(request); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + telemetryRecorder.recordPublicTokenMint("rejected", "invalid_body"); + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const parsedBody = RequestBodySchema.safeParse(body); + if (!parsedBody.success) { + telemetryRecorder.recordPublicTokenMint("rejected", "invalid_body"); + return json( + { error: "Invalid request body", issues: parsedBody.error.issues }, + { status: 400 } + ); + } + + const scopeCheck = scopesWithinAbility(parsedBody.data.scopes, authResult.ability); + if (!scopeCheck.ok) { + telemetryRecorder.recordPublicTokenMint("rejected", "scope_not_allowed"); + return json( + { + error: "Requested scopes exceed the API key's access", + code: "scopes_exceed_key_access", + deniedScopes: scopeCheck.deniedScopes, + }, + { status: 403 } + ); + } + + const expirationTime = parsedBody.data.expirationTime ?? "15m"; + const now = Math.floor(Date.now() / 1000); + const expiresAt = expirationTimestamp(expirationTime, now); + if (expiresAt === undefined) { + telemetryRecorder.recordPublicTokenMint("rejected", "invalid_expiration"); + return json({ error: "Invalid expiration time" }, { status: 400 }); + } + // `expirationTimestamp` accepts past values ("-5m", "5m ago"), which would + // otherwise mint an already-expired token behind a 200. + if (expiresAt <= now) { + telemetryRecorder.recordPublicTokenMint("rejected", "expiration_not_future"); + return json({ error: "Expiration time must be in the future" }, { status: 400 }); + } + if (expiresAt - now > MAX_PUBLIC_TOKEN_LIFETIME_SECONDS) { + telemetryRecorder.recordPublicTokenMint("rejected", "expiration_too_long"); + return json({ error: "Expiration time cannot exceed 30 days" }, { status: 400 }); + } + + let token: string; + try { + token = await generateJWT({ + secretKey: resolveJwtSigningKey(authResult.environment), + payload: { + sub: authResult.environment.id, + pub: true, + scopes: parsedBody.data.scopes, + ...(parsedBody.data.oneTimeUse ? { otu: true } : {}), + ...(parsedBody.data.realtime ? { realtime: parsedBody.data.realtime } : {}), + }, + // Pass the absolute `exp` validated above, not the original string. + // `generateJWT` hands the string to jose's own parser, which would leave + // the 30-day cap enforced against a different computation than the one + // that actually sets the claim. + expirationTime: expiresAt, + }); + } catch (error) { + telemetryRecorder.recordPublicTokenMint("error", "signing_failed"); + throw error; + } + + telemetryRecorder.recordPublicTokenMint("success"); + return json({ token }); +} diff --git a/apps/webapp/app/services/rbac.server.ts b/apps/webapp/app/services/rbac.server.ts index 11510c1358f..fc1cfd58cf1 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 ed991214309..2806a737017 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/apiKeys.test.ts b/apps/webapp/app/utils/apiKeys.test.ts new file mode 100644 index 00000000000..26e7d697da6 --- /dev/null +++ b/apps/webapp/app/utils/apiKeys.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { + apiKeyPrefix, + generateAdditionalApiKey, + generateRootApiKey, + hashApiKey, + obfuscateApiKey, +} from "./apiKeys"; + +describe("API key utilities", () => { + test.each([ + ["DEVELOPMENT", "tr_dev_"], + ["STAGING", "tr_stg_"], + ["PRODUCTION", "tr_prod_"], + ["PREVIEW", "tr_preview_"], + ] as const)("generates %s keys", (environmentType, prefix) => { + const root = generateRootApiKey(environmentType); + const additional = generateAdditionalApiKey(environmentType); + + expect(root.apiKey).toMatch(new RegExp(`^${prefix}[A-Za-z0-9]{24}$`)); + expect(root.keyHash).toBe(hashApiKey(root.apiKey)); + expect(root.lastFour).toBe(root.apiKey.slice(-4)); + expect(additional.apiKey).toMatch(new RegExp(`^${prefix}sk_[A-Za-z0-9]{24}$`)); + expect(additional.keyHash).toBe(hashApiKey(additional.apiKey)); + expect(additional.lastFour).toBe(additional.apiKey.slice(-4)); + expect(apiKeyPrefix(environmentType)).toBe(prefix); + expect(obfuscateApiKey(environmentType, root.lastFour)).toBe( + `${prefix}••••••••${root.lastFour}` + ); + expect(obfuscateApiKey(environmentType, additional.lastFour, "additional")).toBe( + `${prefix}sk_••••••••${additional.lastFour}` + ); + }); + + test("generates unique keys", () => { + const keys = new Set( + Array.from({ length: 100 }, () => generateAdditionalApiKey("PRODUCTION").apiKey) + ); + + expect(keys.size).toBe(100); + }); +}); diff --git a/apps/webapp/app/utils/apiKeys.ts b/apps/webapp/app/utils/apiKeys.ts new file mode 100644 index 00000000000..556c203a49d --- /dev/null +++ b/apps/webapp/app/utils/apiKeys.ts @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { customAlphabet } from "nanoid"; + +const apiKeyId = customAlphabet( + "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + 24 +); + +export function hashApiKey(apiKey: string): string { + return createHash("sha256").update(apiKey, "utf8").digest("hex"); +} + +function generatedApiKey(apiKey: string) { + return { + apiKey, + keyHash: hashApiKey(apiKey), + lastFour: apiKey.slice(-4), + }; +} + +export function generateRootApiKey(environmentType: RuntimeEnvironmentType) { + // Root keys intentionally use the same 24-character entropy as additional keys. + return generatedApiKey(`${apiKeyPrefix(environmentType)}${apiKeyId()}`); +} + +export function generateAdditionalApiKey(environmentType: RuntimeEnvironmentType) { + return generatedApiKey(`${apiKeyPrefix(environmentType)}sk_${apiKeyId()}`); +} + +export function apiKeyPrefix(environmentType: RuntimeEnvironmentType): string { + switch (environmentType) { + case "DEVELOPMENT": + return "tr_dev_"; + case "STAGING": + return "tr_stg_"; + case "PRODUCTION": + return "tr_prod_"; + case "PREVIEW": + return "tr_preview_"; + } +} + +export function obfuscateApiKey( + environmentType: RuntimeEnvironmentType, + lastFour: string, + kind: "root" | "additional" = "root" +): string { + const discriminator = kind === "additional" ? "sk_" : ""; + return `${apiKeyPrefix(environmentType)}${discriminator}••••••••${lastFour}`; +} diff --git a/apps/webapp/app/utils/batchItemAuthorization.ts b/apps/webapp/app/utils/batchItemAuthorization.ts new file mode 100644 index 00000000000..45b1988877d --- /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 00000000000..fac9b96dd20 --- /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 00000000000..fc245ed2428 --- /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 00000000000..a422646c63a --- /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 00000000000..90594f1a0ba --- /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 49a18eaf35f..68fe7dfc41b 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -22,6 +22,13 @@ export const FEATURE_FLAG = { // Grace-linger stamp carried alongside runOpsMintKind on flip. See mintFlipGrace.ts. runOpsMintKindPrev: "runOpsMintKindPrev", runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt", + // Per-organization rollout for creating additional environment API keys. + additionalApiKeysEnabled: "additionalApiKeysEnabled", + // System-wide kill switch for issuing additional environment API keys. + additionalApiKeyIssuanceEnabled: "additionalApiKeyIssuanceEnabled", + // 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 +68,11 @@ 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 booleans prevent a stringified "false" from silently enabling API-key + // creation or lookup. Cold/absent values resolve to the safe `false`. + [FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(), + [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: z.boolean(), + [FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(), }; export type FeatureFlagKey = keyof typeof FeatureFlagCatalog; @@ -79,6 +91,9 @@ export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [ FEATURE_FLAG.taskEventRepository, FEATURE_FLAG.runOpsMintKindPrev, FEATURE_FLAG.runOpsMintKindFlippedAt, + // System-wide only — orgs must not be able to override these kill switches. + FEATURE_FLAG.additionalApiKeyIssuanceEnabled, + 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 258c0da2117..4679ef91b77 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/additionalApiKeyIssuance.test.ts b/apps/webapp/test/additionalApiKeyIssuance.test.ts new file mode 100644 index 00000000000..57c3ca0f833 --- /dev/null +++ b/apps/webapp/test/additionalApiKeyIssuance.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { resolveAdditionalApiKeyIssuance } from "~/services/additionalApiKeyIssuance"; +import { FEATURE_FLAG, FeatureFlagCatalog, ORG_LOCKED_FLAGS } from "~/v3/featureFlags"; + +describe("additional API key issuance controls", () => { + it("registers strict rollout and system-wide flags", () => { + expect( + FeatureFlagCatalog[FEATURE_FLAG.additionalApiKeysEnabled].safeParse("false").success + ).toBe(false); + expect( + FeatureFlagCatalog[FEATURE_FLAG.additionalApiKeyIssuanceEnabled].safeParse("false").success + ).toBe(false); + expect(ORG_LOCKED_FLAGS).not.toContain(FEATURE_FLAG.additionalApiKeysEnabled); + expect(ORG_LOCKED_FLAGS).toContain(FEATURE_FLAG.additionalApiKeyIssuanceEnabled); + }); + + it("defaults to disabled", () => { + expect(resolveAdditionalApiKeyIssuance(undefined, undefined)).toBe(false); + }); + + it("requires the system-wide issuance gate", () => { + expect( + resolveAdditionalApiKeyIssuance( + { [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: false }, + { [FEATURE_FLAG.additionalApiKeysEnabled]: true } + ) + ).toBe(false); + }); + + it("allows an organization override when issuance is enabled", () => { + expect( + resolveAdditionalApiKeyIssuance( + { [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true }, + { [FEATURE_FLAG.additionalApiKeysEnabled]: true } + ) + ).toBe(true); + }); + + it("uses the global rollout value when the organization has no override", () => { + expect( + resolveAdditionalApiKeyIssuance( + { + [FEATURE_FLAG.additionalApiKeysEnabled]: true, + [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true, + }, + undefined + ) + ).toBe(true); + }); + + it("allows an organization to opt out of a global rollout", () => { + expect( + resolveAdditionalApiKeyIssuance( + { + [FEATURE_FLAG.additionalApiKeysEnabled]: true, + [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true, + }, + { [FEATURE_FLAG.additionalApiKeysEnabled]: false } + ) + ).toBe(false); + }); +}); diff --git a/apps/webapp/test/apiAuthScope.test.ts b/apps/webapp/test/apiAuthScope.test.ts new file mode 100644 index 00000000000..5ddf7ffe5ad --- /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 00000000000..be108bf2794 --- /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/apiKeysPresenter.test.ts b/apps/webapp/test/apiKeysPresenter.test.ts new file mode 100644 index 00000000000..82ad8a5e4e5 --- /dev/null +++ b/apps/webapp/test/apiKeysPresenter.test.ts @@ -0,0 +1,201 @@ +import { containerTest } from "@internal/testcontainers"; +import { expect, vi } from "vitest"; +import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; +import { + createRuntimeEnvironment, + createTestOrgProjectWithMember, + createTestUser, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; + +vi.setConfig({ testTimeout: 60_000 }); + +containerTest("binds API key reads to the organization in the route", async ({ prisma }) => { + const first = await createTestOrgProjectWithMember(prisma); + const second = await createTestOrgProjectWithMember(prisma, { userId: first.user.id }); + const environment = await createRuntimeEnvironment(prisma, { + projectId: second.project.id, + organizationId: second.organization.id, + type: "PRODUCTION", + slug: uniqueId("prod"), + }); + const presenter = new ApiKeysPresenter(prisma); + + await expect( + presenter.call({ + userId: first.user.id, + organizationSlug: first.organization.slug, + projectSlug: second.project.slug, + environmentSlug: environment.slug, + }) + ).rejects.toThrow("Environment not found"); +}); + +containerTest( + "describes stored full, catalogued, and unknown policies without exposing scopes", + async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + slug: uniqueId("prod"), + }); + await prisma.apiKey.create({ + data: { + name: "Full access", + keyHash: uniqueId("full-hash"), + lastFour: "full", + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + await prisma.apiKey.create({ + data: { + name: "Restricted access", + keyHash: uniqueId("restricted-hash"), + lastFour: "rstr", + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: "RESTRICTED_TEST_PRESET", + scopes: ["read:deployments"], + }, + }); + await prisma.apiKey.create({ + data: { + name: "Unknown preset", + keyHash: uniqueId("unknown-hash"), + lastFour: "unkn", + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: "REMOVED_PRESET", + scopes: ["trigger:tasks:send-email"], + }, + }); + await prisma.apiKey.create({ + data: { + name: "Restricted without preset", + keyHash: uniqueId("null-restricted-hash"), + lastFour: "null", + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["read:runs"], + }, + }); + await prisma.apiKey.create({ + data: { + name: "Revoked key", + keyHash: uniqueId("revoked-hash"), + lastFour: "rvkd", + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + revokedAt: new Date(), + }, + }); + const describeApiKeyPolicy = vi.fn(async (policy: { scopes: string[] }) => + policy.scopes.includes("trigger:tasks:send-email") ? { taskIdentifiers: ["send-email"] } : {} + ); + const apiKeyPresets = vi.fn().mockResolvedValue([ + { + id: "RESTRICTED_TEST_PRESET", + label: "Restricted access", + description: "Restricted test access", + usesTaskSelection: false, + available: true, + }, + ]); + const presenter = new ApiKeysPresenter(prisma, { describeApiKeyPolicy, apiKeyPresets }); + + const result = await presenter.call({ + userId: user.id, + organizationSlug: organization.slug, + projectSlug: project.slug, + environmentSlug: environment.slug, + }); + const keysByName = new Map(result.apiKeys.map((key) => [key.name, key])); + + expect(describeApiKeyPolicy).toHaveBeenCalledTimes(4); + expect(describeApiKeyPolicy).toHaveBeenCalledWith({ + presetId: null, + scopes: ["admin"], + }); + expect(apiKeyPresets).toHaveBeenCalledWith(organization.id); + expect(result.rootApiKey.obfuscated).toBe(`tr_prod_••••••••${environment.apiKey.slice(-4)}`); + expect(keysByName.get("Full access")?.obfuscated).toBe("tr_prod_sk_••••••••full"); + expect(keysByName.get("Full access")?.access).toMatchObject({ + presetId: null, + label: "Full access", + usesTaskSelection: false, + }); + expect(keysByName.get("Restricted access")?.access).toMatchObject({ + presetId: "RESTRICTED_TEST_PRESET", + label: "Restricted access", + usesTaskSelection: false, + }); + expect(keysByName.get("Restricted without preset")?.access).toMatchObject({ + presetId: null, + label: "Custom", + usesTaskSelection: false, + }); + expect(keysByName.get("Unknown preset")?.access).toEqual({ + presetId: "REMOVED_PRESET", + label: "Custom", + taskIdentifiers: ["send-email"], + usesTaskSelection: true, + }); + expect(keysByName.get("Full access")).not.toHaveProperty("scopes"); + expect(keysByName.get("Restricted access")).not.toHaveProperty("scopes"); + expect(keysByName.has("Revoked key")).toBe(false); + } +); + +containerTest( + "does not expose another member's named development branch keys", + async ({ prisma }) => { + const owner = await createTestOrgProjectWithMember(prisma); + const otherUser = await createTestUser(prisma); + const otherMember = await prisma.orgMember.create({ + data: { + organizationId: owner.organization.id, + userId: otherUser.id, + role: "MEMBER", + }, + }); + const otherRoot = await createRuntimeEnvironment(prisma, { + projectId: owner.project.id, + organizationId: owner.organization.id, + type: "DEVELOPMENT", + orgMemberId: otherMember.id, + slug: uniqueId("other-dev"), + }); + const branch = await prisma.runtimeEnvironment.create({ + data: { + slug: uniqueId("named-branch"), + type: "DEVELOPMENT", + projectId: owner.project.id, + organizationId: owner.organization.id, + orgMemberId: otherMember.id, + parentEnvironmentId: otherRoot.id, + branchName: "feature/secret", + apiKey: uniqueId("api"), + pkApiKey: uniqueId("pk"), + shortcode: uniqueId("sc"), + }, + }); + const presenter = new ApiKeysPresenter(prisma); + + await expect( + presenter.call({ + userId: owner.user.id, + organizationSlug: owner.organization.slug, + projectSlug: owner.project.slug, + environmentSlug: branch.slug, + }) + ).rejects.toThrow("Environment not found"); + } +); diff --git a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts index a46cd0894f6..c393526b9c6 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 988a8f7071c..5a077d2439c 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -913,7 +913,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 +928,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 +2471,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 00000000000..5de10e348ab --- /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/createEnvironmentApiKey.test.ts b/apps/webapp/test/createEnvironmentApiKey.test.ts new file mode 100644 index 00000000000..3ebdc66b382 --- /dev/null +++ b/apps/webapp/test/createEnvironmentApiKey.test.ts @@ -0,0 +1,343 @@ +import { containerTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import rbacPlugin, { type RoleBaseAccessController } from "@trigger.dev/rbac"; +import { expect, vi } from "vitest"; +import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts"; +import { createEnvironmentApiKey, revokeEnvironmentApiKey } from "~/models/api-key.server"; +import type { ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { + createRuntimeEnvironment, + createTestOrgProjectWithMember, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; + +vi.setConfig({ testTimeout: 60_000 }); + +function policyController( + implementation: RoleBaseAccessController["prepareApiKeyPolicy"] +): Pick { + return { prepareApiKeyPolicy: vi.fn(implementation) }; +} + +function telemetryRecorder(): ApiKeyTelemetry { + return { + recordOperation: vi.fn(), + recordPublicTokenMint: vi.fn(), + }; +} + +async function setup(prisma: PrismaClient) { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const [environment] = await Promise.all([ + createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + slug: uniqueId("prod"), + }), + prisma.organization.update({ + where: { id: organization.id }, + data: { featureFlags: { [FEATURE_FLAG.additionalApiKeysEnabled]: true } }, + }), + prisma.featureFlag.upsert({ + where: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled }, + create: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled, value: true }, + update: { value: true }, + }), + ]); + return { organization, project, user, environment }; +} + +containerTest( + "rejects creation when the system-wide issuance gate is disabled", + async ({ prisma }) => { + const { user, environment } = await setup(prisma); + await prisma.featureFlag.update({ + where: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled }, + data: { value: false }, + }); + const controller = policyController(async () => ({ + ok: true, + policy: { presetId: null, scopes: ["admin"] }, + })); + + await expect( + createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Disabled", + presetId: "FULL_ACCESS", + }, + { prismaClient: prisma, rbacController: controller } + ) + ).rejects.toThrow("Creating additional API keys is not enabled"); + + expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled(); + await expect( + prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } }) + ).resolves.toBe(0); + } +); + +containerTest("standalone fallback creates one explicit full-access key", async ({ prisma }) => { + const { user, environment } = await setup(prisma); + const fallback = rbacPlugin.create({ primary: prisma, replica: prisma }, { forceFallback: true }); + const telemetry = telemetryRecorder(); + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + const result = await createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Full access", + expiresAt, + presetId: "FULL_ACCESS", + }, + { prismaClient: prisma, rbacController: fallback, telemetryRecorder: telemetry } + ); + + expect(telemetry.recordOperation).toHaveBeenNthCalledWith(1, "prepare_policy", "success"); + expect(telemetry.recordOperation).toHaveBeenNthCalledWith(2, "create", "success"); + expect(result.plaintext).toMatch(/^tr_prod_sk_[A-Za-z0-9]{24}$/); + expect(result.apiKey).toMatchObject({ + presetId: null, + scopes: ["admin"], + expiresAt, + }); + await expect( + prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } }) + ).resolves.toBe(1); +}); + +containerTest("records successful API key revocation", async ({ prisma }) => { + const { user, environment } = await setup(prisma); + const apiKey = await prisma.apiKey.create({ + data: { + name: "Revoke me", + keyHash: uniqueId("hash"), + lastFour: "last", + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + scopes: ["admin"], + }, + }); + const telemetry = telemetryRecorder(); + + await revokeEnvironmentApiKey( + { environmentId: environment.id, apiKeyId: apiKey.id }, + { prismaClient: prisma, telemetryRecorder: telemetry } + ); + + expect(telemetry.recordOperation).toHaveBeenCalledWith("revoke", "success"); + await expect(prisma.apiKey.findUnique({ where: { id: apiKey.id } })).resolves.toMatchObject({ + revokedAt: expect.any(Date), + }); +}); + +containerTest("persists trusted full-access and restricted cloud policies", async ({ prisma }) => { + const { organization, user, environment } = await setup(prisma); + const fullAccessController = policyController(async () => ({ + ok: true, + policy: { presetId: "FULL_ACCESS", scopes: ["admin"] }, + })); + const restrictedController = policyController(async () => ({ + ok: true, + policy: { + presetId: "DEPLOYMENT_READ_ONLY", + scopes: ["read:deployments", "read:tasks"], + }, + })); + + const fullAccess = await createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Cloud full access", + presetId: "FULL_ACCESS", + }, + { prismaClient: prisma, rbacController: fullAccessController } + ); + const restricted = await createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Restricted", + presetId: "DEPLOYMENT_READ_ONLY", + }, + { prismaClient: prisma, rbacController: restrictedController } + ); + + expect(fullAccess.apiKey).toMatchObject({ presetId: "FULL_ACCESS", scopes: ["admin"] }); + expect(restricted.apiKey).toMatchObject({ + presetId: "DEPLOYMENT_READ_ONLY", + scopes: ["read:deployments", "read:tasks"], + }); + expect(fullAccessController.prepareApiKeyPolicy).toHaveBeenCalledWith({ + organizationId: organization.id, + presetId: "FULL_ACCESS", + taskIdentifiers: undefined, + }); +}); + +containerTest("policy preparation failure inserts no credential", async ({ prisma }) => { + const { user, environment } = await setup(prisma); + const controller = policyController(async () => ({ + ok: false, + error: "This API key access preset is not available on your plan", + })); + const telemetry = telemetryRecorder(); + + await expect( + createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Unavailable", + presetId: "RESTRICTED", + }, + { prismaClient: prisma, rbacController: controller, telemetryRecorder: telemetry } + ) + ).rejects.toThrow("not available on your plan"); + + expect(telemetry.recordOperation).toHaveBeenCalledWith( + "prepare_policy", + "rejected", + "policy_rejected" + ); + await expect( + prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } }) + ).resolves.toBe(0); +}); + +containerTest("rejects expired credentials before policy preparation", async ({ prisma }) => { + const { user, environment } = await setup(prisma); + const controller = policyController(async () => ({ + ok: true, + policy: { presetId: null, scopes: ["admin"] }, + })); + + await expect( + createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Already expired", + expiresAt: new Date(Date.now() - 1_000), + presetId: "FULL_ACCESS", + }, + { prismaClient: prisma, rbacController: controller } + ) + ).rejects.toThrow("Expiration must be in the future"); + + expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled(); + await expect( + prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } }) + ).resolves.toBe(0); +}); + +containerTest("rejects too many task identifiers before policy preparation", async ({ prisma }) => { + const { user, environment } = await setup(prisma); + const controller = policyController(async () => ({ + ok: true, + policy: { presetId: "TASKS", scopes: ["trigger:tasks"] }, + })); + + await expect( + createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Too many tasks", + presetId: "TASKS", + taskIdentifiers: Array.from( + { length: MAX_API_KEY_TASK_IDENTIFIERS + 1 }, + (_, index) => `task-${index}` + ), + }, + { prismaClient: prisma, rbacController: controller } + ) + ).rejects.toThrow(`at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks`); + + expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled(); +}); + +containerTest("unknown task identifiers insert no credential", async ({ prisma }) => { + const { user, environment } = await setup(prisma); + const controller = policyController(async () => ({ + ok: true, + policy: { presetId: "TASKS", scopes: ["trigger:tasks:not-real"] }, + })); + + await expect( + createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Unknown task", + presetId: "TASKS", + taskIdentifiers: ["not-real"], + }, + { prismaClient: prisma, rbacController: controller } + ) + ).rejects.toThrow("not available in this environment"); + + expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled(); + await expect( + prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } }) + ).resolves.toBe(0); +}); + +containerTest( + "deduplicates task input and persists only the trusted policy", + async ({ prisma }) => { + const { organization, project, user, environment } = await setup(prisma); + await prisma.taskIdentifier.createMany({ + data: [ + { + runtimeEnvironmentId: environment.id, + projectId: project.id, + slug: "send-email", + }, + { + runtimeEnvironmentId: environment.id, + projectId: project.id, + slug: "sync-data", + }, + ], + }); + const trustedScopes = ["trigger:tasks:send-email", "trigger:tasks:sync-data", "read:runs"]; + const controller = policyController(async () => ({ + ok: true, + policy: { presetId: "TRIGGER_ONLY", scopes: trustedScopes }, + })); + + const result = await createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Selected tasks", + presetId: "TRIGGER_ONLY", + taskIdentifiers: [" send-email ", "sync-data", "send-email"], + }, + { prismaClient: prisma, rbacController: controller } + ); + + expect(controller.prepareApiKeyPolicy).toHaveBeenCalledWith({ + organizationId: organization.id, + presetId: "TRIGGER_ONLY", + taskIdentifiers: ["send-email", "sync-data"], + }); + expect(result.apiKey).toMatchObject({ presetId: "TRIGGER_ONLY", scopes: trustedScopes }); + } +); diff --git a/apps/webapp/test/environmentVariableApiAccess.test.ts b/apps/webapp/test/environmentVariableApiAccess.test.ts new file mode 100644 index 00000000000..703df55895a --- /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 5bbc1e97834..c301a02b201 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 b50d8ad9400..0b18095319c 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 00000000000..62ca5c9b055 --- /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 00000000000..403167cfe1a --- /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/publicTokensRoute.test.ts b/apps/webapp/test/publicTokensRoute.test.ts new file mode 100644 index 00000000000..f946475882b --- /dev/null +++ b/apps/webapp/test/publicTokensRoute.test.ts @@ -0,0 +1,302 @@ +import { postgresTest } from "@internal/testcontainers"; +import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; +import type { PrismaClient } from "@trigger.dev/database"; +import { buildJwtAbility } from "@trigger.dev/plugins"; +import rbacPlugin, { type RbacAbility, type RoleBaseAccessController } from "@trigger.dev/rbac"; +import { describe, expect, it, vi } from "vitest"; +import type { ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server"; +import { handlePublicTokenRequest } from "~/services/publicTokens.server"; +import { generateAdditionalApiKey, generateRootApiKey, hashApiKey } from "~/utils/apiKeys"; +import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; + +function request(body: unknown, accessToken = "tr_prod_test", expirationTime?: string | number) { + return new Request("https://api.trigger.dev/api/v1/auth/public-tokens", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify( + expirationTime === undefined ? body : { ...(body as object), expirationTime } + ), + }); +} + +const environment = { + id: "env_test", + apiKey: "tr_prod_root_signing_secret", + parentEnvironment: null, +}; + +const permissiveAbility: RbacAbility = { + can: () => true, + canSuper: () => false, +}; + +function controllerWithAbility( + ability: RbacAbility, + subject: "root" | "additional" = "additional" +) { + return { + async authenticateBearer() { + return { + ok: true as const, + environment, + subject: + subject === "root" + ? { + type: "user" as const, + userId: "user_test", + organizationId: "org_test", + } + : { + type: "apiKey" as const, + apiKeyId: "key_test", + restricted: ability !== permissiveAbility, + organizationId: "org_test", + }, + ability, + }; + }, + } as unknown as Pick; +} + +async function responseJson(response: Response) { + return response.json() as Promise>; +} + +function telemetryRecorder(): ApiKeyTelemetry { + return { + recordOperation: vi.fn(), + recordPublicTokenMint: vi.fn(), + }; +} + +describe("POST /api/v1/auth/public-tokens", () => { + it("lets root and unrestricted additional keys mint arbitrary scopes", async () => { + for (const controller of [ + controllerWithAbility(permissiveAbility, "root"), + controllerWithAbility(permissiveAbility, "additional"), + ]) { + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs", "custom:resources:value"] }), + controller + ); + expect(response.status).toBe(200); + + const { token } = await responseJson(response); + const validation = await validateJWT(token, environment.apiKey); + expect(validation.ok).toBe(true); + if (!validation.ok) continue; + expect(validation.payload).toMatchObject({ + sub: environment.id, + pub: true, + scopes: ["read:runs", "custom:resources:value"], + }); + } + }); + + it("records successful and rejected mint outcomes", async () => { + const telemetry = telemetryRecorder(); + const controller = controllerWithAbility(buildJwtAbility(["read:runs"])); + + const allowed = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }), + controller, + telemetry + ); + const denied = await handlePublicTokenRequest( + request({ scopes: ["write:runs"] }), + controller, + telemetry + ); + + expect(allowed.status).toBe(200); + expect(denied.status).toBe(403); + expect(telemetry.recordPublicTokenMint).toHaveBeenNthCalledWith(1, "success"); + expect(telemetry.recordPublicTokenMint).toHaveBeenNthCalledWith( + 2, + "rejected", + "scope_not_allowed" + ); + }); + + it("allows restricted subsets and rejects excess scopes", async () => { + const controller = controllerWithAbility( + buildJwtAbility(["read:runs", "trigger:tasks:send-email"]) + ); + + const allowed = await handlePublicTokenRequest( + request({ scopes: ["read:runs:run_123", "trigger:tasks:send-email"] }), + controller + ); + expect(allowed.status).toBe(200); + + const denied = await handlePublicTokenRequest( + request({ scopes: ["read:runs", "write:runs", "trigger:tasks"] }), + controller + ); + expect(denied.status).toBe(403); + await expect(responseJson(denied)).resolves.toMatchObject({ + code: "scopes_exceed_key_access", + deniedScopes: ["write:runs", "trigger:tasks"], + }); + }); + + it("rejects a type-level scope when the key only has per-id access", async () => { + const response = await handlePublicTokenRequest( + request({ scopes: ["trigger:tasks"] }), + controllerWithAbility(buildJwtAbility(["trigger:tasks:send-email"])) + ); + + expect(response.status).toBe(403); + await expect(responseJson(response)).resolves.toMatchObject({ + deniedScopes: ["trigger:tasks"], + }); + }); + + it("rejects empty scopes", async () => { + const response = await handlePublicTokenRequest( + request({ scopes: [] }), + controllerWithAbility(permissiveAbility) + ); + + expect(response.status).toBe(400); + }); + + it("rejects expirations longer than 30 days", async () => { + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }, undefined, "31d"), + controllerWithAbility(permissiveAbility) + ); + + expect(response.status).toBe(400); + await expect(responseJson(response)).resolves.toMatchObject({ + error: "Expiration time cannot exceed 30 days", + }); + }); + + it.each(["-5m", "5m ago"])("rejects an expiration in the past (%s)", async (expirationTime) => { + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }, undefined, expirationTime), + controllerWithAbility(permissiveAbility) + ); + + expect(response.status).toBe(400); + await expect(responseJson(response)).resolves.toMatchObject({ + error: "Expiration time must be in the future", + }); + }); + + it("signs the exp it validated rather than re-parsing the input string", async () => { + const before = Math.floor(Date.now() / 1000); + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }, undefined, "10m"), + controllerWithAbility(permissiveAbility) + ); + + expect(response.status).toBe(200); + const { token } = (await responseJson(response)) as { token: string }; + const validation = await validateJWT(token, environment.apiKey); + + expect(validation.ok).toBe(true); + // A single parse governs both the cap check and the claim. + const exp = (validation as { payload: { exp: number } }).payload.exp; + expect(exp).toBeGreaterThanOrEqual(before + 600); + expect(exp).toBeLessThanOrEqual(before + 601); + }); + + it("does not allow a public JWT bearer to mint another token", async () => { + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { sub: environment.id, pub: true, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const controller = { + async authenticateBearer(_request: Request, options?: { allowJWT?: boolean }) { + return options?.allowJWT + ? ({ + ok: true, + environment, + subject: { + type: "publicJWT", + environmentId: environment.id, + organizationId: "org_test", + }, + ability: buildJwtAbility(["read:runs"]), + } as const) + : ({ ok: false, status: 401, error: "Invalid API key" } as const); + }, + } as unknown as Pick; + + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }, jwt), + controller + ); + + expect(response.status).toBe(401); + }); +}); + +function makeController(prisma: PrismaClient) { + return rbacPlugin.create({ primary: prisma, replica: prisma }, { forceFallback: true }); +} + +postgresTest( + "minted tokens round-trip after the root key is rotated", + async ({ prisma }) => { + const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); + const originalRootKey = generateRootApiKey("PRODUCTION").apiKey; + const rotatedSigningKey = generateRootApiKey("PRODUCTION").apiKey; + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug: uniqueId("env"), + apiKey: originalRootKey, + pkApiKey: uniqueId("pk"), + shortcode: uniqueId("sc"), + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + orgMemberId: orgMember.id, + }, + }); + const additionalKey = generateAdditionalApiKey("PRODUCTION").apiKey; + await prisma.apiKey.create({ + data: { + name: "Token minter", + keyHash: hashApiKey(additionalKey), + lastFour: additionalKey.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: "READ_ONLY", + scopes: ["read:runs"], + }, + }); + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { apiKey: rotatedSigningKey }, + }); + + const controller = makeController(prisma); + const mintResponse = await handlePublicTokenRequest( + request({ scopes: ["read:runs"], oneTimeUse: true }, additionalKey), + controller + ); + expect(mintResponse.status).toBe(200); + const { token } = await responseJson(mintResponse); + + const authResult = await controller.authenticateBearer( + new Request("https://api.trigger.dev/api/v1/runs", { + headers: { Authorization: `Bearer ${token}` }, + }), + { allowJWT: true } + ); + + expect(authResult.ok).toBe(true); + if (!authResult.ok) return; + expect(authResult.environment.id).toBe(environment.id); + expect(authResult.jwt?.oneTimeUse).toBe(true); + expect(authResult.ability.can("read", { type: "runs" })).toBe(true); + }, + 60_000 +); diff --git a/apps/webapp/test/rbacFallbackBranch.test.ts b/apps/webapp/test/rbacFallbackBranch.test.ts index f8e90e444ce..cf5a9f0b77a 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 00000000000..0ea800a1049 --- /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/database/prisma/migrations/20260723112558_add_environment_api_keys/migration.sql b/internal-packages/database/prisma/migrations/20260723112558_add_environment_api_keys/migration.sql new file mode 100644 index 00000000000..63957b702df --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260723112558_add_environment_api_keys/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "public"."api_keys" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "key_hash" TEXT NOT NULL, + "last_four" TEXT NOT NULL, + "runtime_environment_id" TEXT NOT NULL, + "created_by_user_id" TEXT, + "preset_id" TEXT, + "scopes" TEXT[] NOT NULL, + "last_used_at" TIMESTAMP(3), + "revoked_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "public"."api_keys"("key_hash"); + +-- CreateIndex +CREATE INDEX "api_keys_runtime_environment_id_revoked_at_created_at_idx" ON "public"."api_keys"("runtime_environment_id", "revoked_at", "created_at" DESC); + +-- AddForeignKey +ALTER TABLE "public"."api_keys" ADD CONSTRAINT "api_keys_runtime_environment_id_fkey" FOREIGN KEY ("runtime_environment_id") REFERENCES "public"."RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."api_keys" ADD CONSTRAINT "api_keys_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7d6f4ac5493..2d220fbf25f 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -69,6 +69,7 @@ model User { invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) invitationCodeId String? personalAccessTokens PersonalAccessToken[] + createdApiKeys ApiKey[] deployments WorkerDeployment[] backupCodes MfaBackupCode[] bulkActions BulkActionGroup[] @@ -392,6 +393,7 @@ model RuntimeEnvironment { playgroundConversations PlaygroundConversation[] errorGroupStates ErrorGroupState[] taskIdentifiers TaskIdentifier[] + apiKeys ApiKey[] revokedApiKeys RevokedApiKey[] // A partial unique index also enforces one STAGING/PREVIEW root per project and type. @@ -403,6 +405,31 @@ model RuntimeEnvironment { @@index([organizationId]) } +model ApiKey { + id String @id @default(cuid()) + name String + keyHash String @unique @map("key_hash") + lastFour String @map("last_four") + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String @map("runtime_environment_id") + + createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull, onUpdate: Cascade) + createdByUserId String? @map("created_by_user_id") + + presetId String? @map("preset_id") + scopes String[] + + lastUsedAt DateTime? @map("last_used_at") + revokedAt DateTime? @map("revoked_at") + expiresAt DateTime? @map("expires_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") + + @@index([runtimeEnvironmentId, revokedAt, createdAt(sort: Desc)]) + @@map("api_keys") +} + /// Records of previously-valid API keys that are still accepted for authentication /// during a grace window after rotation. Extend or end the grace period by updating `expiresAt`. model RevokedApiKey { diff --git a/internal-packages/rbac/src/ability.test.ts b/internal-packages/rbac/src/ability.test.ts index a3250bd0030..3497ba66690 100644 --- a/internal-packages/rbac/src/ability.test.ts +++ b/internal-packages/rbac/src/ability.test.ts @@ -5,6 +5,7 @@ import { denyAbility, buildFallbackAbility, buildJwtAbility, + scopesWithinAbility, } from "./ability.js"; describe("permissiveAbility", () => { @@ -123,6 +124,43 @@ describe("buildJwtAbility", () => { }); }); +describe("scopesWithinAbility", () => { + it("allows subsets and preserves ids containing colons", () => { + const result = scopesWithinAbility( + ["read:runs:run_abc", "read:tags:env:staging"], + buildJwtAbility(["read:runs", "read:tags:env:staging"]) + ); + + expect(result).toEqual({ ok: true, deniedScopes: [] }); + }); + + it("rejects scopes that broaden or exceed the ability", () => { + const result = scopesWithinAbility( + ["trigger:tasks:send-email", "trigger:tasks", "read:runs"], + buildJwtAbility(["trigger:tasks:send-email"]) + ); + + expect(result).toEqual({ + ok: false, + deniedScopes: ["trigger:tasks", "read:runs"], + }); + }); + + it("allows arbitrary valid scopes for a permissive ability", () => { + expect(scopesWithinAbility(["read:runs", "admin"], permissiveAbility)).toEqual({ + ok: true, + deniedScopes: [], + }); + }); + + it("rejects malformed scopes for restricted abilities", () => { + expect(scopesWithinAbility(["read"], buildJwtAbility(["read:all"]))).toEqual({ + ok: false, + deniedScopes: ["read"], + }); + }); +}); + describe("buildJwtAbility — array resources", () => { it("authorizes when any resource in the array passes a scope check", () => { const ability = buildJwtAbility(["read:batch:batch_abc"]); diff --git a/internal-packages/rbac/src/ability.ts b/internal-packages/rbac/src/ability.ts index 6cf22d7643e..9cd5cab68e7 100644 --- a/internal-packages/rbac/src/ability.ts +++ b/internal-packages/rbac/src/ability.ts @@ -4,7 +4,7 @@ import type { RbacAbility } from "@trigger.dev/plugins"; // @trigger.dev/plugins so a public token decodes identically whoever // serves the request. Re-exported here so existing importers keep their // `./ability.js` import. -export { buildJwtAbility } from "@trigger.dev/plugins"; +export { buildJwtAbility, scopesWithinAbility } from "@trigger.dev/plugins"; /** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */ export const permissiveAbility: RbacAbility = { diff --git a/internal-packages/rbac/src/apiKeyPolicies.test.ts b/internal-packages/rbac/src/apiKeyPolicies.test.ts new file mode 100644 index 00000000000..015ba1252e1 --- /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/apiKeyPolicyDefaults.test.ts b/internal-packages/rbac/src/apiKeyPolicyDefaults.test.ts new file mode 100644 index 00000000000..c023bea1c3f --- /dev/null +++ b/internal-packages/rbac/src/apiKeyPolicyDefaults.test.ts @@ -0,0 +1,58 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect, it, vi } from "vitest"; + +// The API-key policy methods are OPTIONAL on RoleBaseAccessController so a +// plugin compiled against an older OSS commit still satisfies the contract +// (the plugin is built against whichever OSS source its base image carries). +// LazyController is what turns that partial surface into a total one, and these +// tests pin the defaults it substitutes — in particular that a missing +// prepareApiKeyPolicy fails CLOSED rather than resolving to full access. +// +// A stand-in for the cloud plugin, which isn't installed in this repo. The +// factory supplies the specifier, so no real module has to resolve. +vi.mock("@triggerdotdev/plugins/rbac", () => ({ + default: { + create: () => ({ + // Deliberately omits apiKeyPresets / prepareApiKeyPolicy / + // describeApiKeyPolicy — this is a pre-contract plugin. + isUsingPlugin: async () => true, + }), + }, +})); + +const prismaPlaceholder = {} as unknown as PrismaClient; + +describe("LazyController API-key policy defaults (plugin predates the contract)", () => { + async function controller() { + const loader = (await import("./index.js")).default; + const instance = loader.create(prismaPlaceholder); + // Guard against a silent fallback: if the mock didn't take, these + // assertions would be checking the fallback's real implementations. + await expect(instance.isUsingPlugin()).resolves.toBe(true); + return instance; + } + + it("reports no preset catalogue rather than throwing", async () => { + await expect((await controller()).apiKeyPresets("org_123")).resolves.toBeNull(); + }); + + it("refuses to prepare a policy — including FULL_ACCESS", async () => { + const result = await ( + await controller() + ).prepareApiKeyPolicy({ + organizationId: "org_123", + presetId: "FULL_ACCESS", + }); + + // The critical assertion: absence must never resolve to `{ ok: true }` with + // an admin scope. A plugin below the contract cannot mint any credential. + expect(result.ok).toBe(false); + expect(result).not.toHaveProperty("policy"); + }); + + it("describes a policy as having nothing extra to show", async () => { + await expect( + (await controller()).describeApiKeyPolicy({ presetId: "TRIGGER_ONLY", scopes: ["read:runs"] }) + ).resolves.toEqual({}); + }); +}); diff --git a/internal-packages/rbac/src/bearerCredentials.ts b/internal-packages/rbac/src/bearerCredentials.ts new file mode 100644 index 00000000000..64dcd82372f --- /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 7ada941f755..9054c0a43e1 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, @@ -13,12 +12,15 @@ import type { RoleMutationResult, UserActorAuthResult, } from "@trigger.dev/plugins"; -import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/plugins"; +import { + FULL_ACCESS_PRESET_ID, + isUserActorToken, + verifyUserActorToken, +} 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 @@ -44,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 { @@ -64,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 { @@ -79,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( @@ -374,6 +222,34 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { return null; } + async apiKeyPresets(_organizationId: string) { + return null; + } + + async prepareApiKeyPolicy(params: { + organizationId: string; + presetId: string; + 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 + // 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 + // key is full-access, not an instance of a named preset. + return { + ok: true as const, + policy: { presetId: null, scopes: ["admin"] }, + }; + } + + async describeApiKeyPolicy() { + return {}; + } + async allPermissions(): Promise { return []; } @@ -431,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 eb34da4f0a1..c09312363da 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -1,5 +1,9 @@ import type { + ApiKeyPolicyDescription, + ApiKeyPreset, + BearerAuthResult, Permission, + PrepareApiKeyPolicyResult, RbacAbility, RbacDatabaseConfig, Role, @@ -10,9 +14,40 @@ 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 + * defaults for the optional capability methods a plugin may not implement. + * + * `RoleBaseAccessController` is the *plugin-facing* contract, where capability + * extensions are optional so an older plugin still satisfies it. Host code + * always talks to the LazyController singleton (`rbac`), which never omits a + * 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 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 { @@ -39,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 @@ -68,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 @@ -87,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"; @@ -146,6 +193,7 @@ class LazyController implements RoleBaseAccessController { return new RoleBaseAccessFallback(prisma, { userActorSecret: options?.userActorSecret, + additionalApiKeyLookupEnabled: options?.additionalApiKeyLookupEnabled, }).create(); } } @@ -159,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) { @@ -213,6 +308,41 @@ class LazyController implements RoleBaseAccessController { return (await this.c()).systemRoles(...args); } + // The API-key policy methods are optional on the controller contract (see the + // note on RoleBaseAccessController) so a plugin compiled against an older OSS + // commit still satisfies it. LazyController is where that optional surface is + // normalized into a total one: every host caller goes through `rbac`, so the + // absent-plugin default lives here once instead of at each call site. + async apiKeyPresets( + ...args: Parameters> + ): Promise { + const controller = await this.c(); + // Same meaning as the no-plugin fallback: no catalogue to offer. + return controller.apiKeyPresets ? controller.apiKeyPresets(...args) : null; + } + + async prepareApiKeyPolicy( + ...args: Parameters> + ): Promise { + const controller = await this.c(); + if (!controller.prepareApiKeyPolicy) { + // Fail closed. A plugin that predates this contract must not be able to + // mint a credential — least of all a full-access one — so creation stops + // outright. Keys already issued are unaffected: they authorize from the + // scopes persisted on their row, compiled by the host bearer resolver. + return { ok: false, error: "API key access presets are not available" }; + } + return controller.prepareApiKeyPolicy(...args); + } + + async describeApiKeyPolicy( + ...args: Parameters> + ): Promise { + const controller = await this.c(); + // Presentation only — an undescribed policy renders from its stored scopes. + return controller.describeApiKeyPolicy ? controller.describeApiKeyPolicy(...args) : {}; + } + async allPermissions( ...args: Parameters ): Promise { @@ -293,7 +423,13 @@ class LazyController implements RoleBaseAccessController { class RoleBaseAccess { // Synchronous — returns a lazy controller that resolves any installed // plugin on first call. - create(prisma: RbacPrismaInput, options?: RbacCreateOptions): RoleBaseAccessController { + // + // Returns HostRbacController, not RoleBaseAccessController: the latter is the + // plugin-facing contract whose capability methods are optional, and + // LazyController has already substituted defaults for any the installed plugin + // omits. Handing back the total surface is what keeps host callers from having + // to guard (or, worse, from inventing their own absent-plugin default). + create(prisma: RbacPrismaInput, options?: RbacCreateOptions): HostRbacController { return new LazyController(prisma, options); } } diff --git a/packages/core/package.json b/packages/core/package.json index f0c3adfcf2f..c0ebe06ff80 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/jwt.ts b/packages/core/src/v3/jwt.ts index 6845225be8a..b1dec457ada 100644 --- a/packages/core/src/v3/jwt.ts +++ b/packages/core/src/v3/jwt.ts @@ -14,6 +14,40 @@ export const JWT_ALGORITHM = "HS256"; export const JWT_ISSUER = "https://id.trigger.dev"; export const JWT_AUDIENCE = "https://api.trigger.dev"; +function decodeJWTPayload(token: string): unknown { + const parts = token.split("."); + const encodedPayload = parts[1]; + if (parts.length !== 3 || !encodedPayload) return; + + try { + const base64 = encodedPayload + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(encodedPayload.length / 4) * 4, "="); + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return; + } +} + +export function isPublicJWT(token: string): boolean { + const payload = decodeJWTPayload(token); + return ( + payload !== null && typeof payload === "object" && "pub" in payload && payload.pub === true + ); +} + +export function extractJWTSub(token: string): string | undefined { + const payload = decodeJWTPayload(token); + return payload !== null && + typeof payload === "object" && + "sub" in payload && + typeof payload.sub === "string" + ? payload.sub + : undefined; +} + export async function generateJWT(options: GenerateJWTOptions): Promise { const { SignJWT } = await import("jose"); diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 1c561d7452a..198efce3fc3 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -18,10 +18,22 @@ export type { RbacPluginConfig, RbacDatabaseConfig, SystemRole, + ApiKeyPreset, + ApiKeyPolicy, + PrepareApiKeyPolicyResult, + ApiKeyPolicyDescription, AuthenticatedEnvironment, + RbacScopeAction, + RbacScopeResourceType, } from "./rbac.js"; -export { buildJwtAbility } from "./rbac.js"; +export { + buildJwtAbility, + buildScope, + FULL_ACCESS_PRESET_ID, + scopesGrantFullAccess, + scopesWithinAbility, +} from "./rbac.js"; export { isUserActorToken, signUserActorToken, diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts index b31abd1215b..52c6da8fdc9 100644 --- a/packages/plugins/src/rbac.ts +++ b/packages/plugins/src/rbac.ts @@ -19,6 +19,27 @@ export type SystemRole = { available: boolean; }; +export type ApiKeyPreset = { + id: string; + label: string; + description: string; + usesTaskSelection: boolean; + available: boolean; +}; + +export type ApiKeyPolicy = { + presetId: string | null; + scopes: string[]; +}; + +export type PrepareApiKeyPolicyResult = + | { ok: true; policy: ApiKeyPolicy } + | { ok: false; error: string }; + +export type ApiKeyPolicyDescription = { + taskIdentifiers?: string[]; +}; + export type Permission = { // `:` — display name, derived from the ability rule. name: string; @@ -47,6 +68,18 @@ export type RbacSubject = | { type: "user"; userId: string; organizationId: string; projectId?: string } | { type: "personalAccessToken"; tokenId: string; organizationId: string; projectId?: string } | { type: "publicJWT"; environmentId: string; organizationId: string; projectId?: string } + // A host-owned additional environment API key. The host builds its ability + // from the effective scopes stored with the credential. Route builders use + // `restricted` to fail closed when a scoped key reaches an endpoint without a + // declared authorization resource. `apiKeyId` identifies the key for + // attribution. Root/legacy environment keys keep the `user` subject. + | { + type: "apiKey"; + apiKeyId: string; + restricted: boolean; + organizationId: string; + projectId?: string; + } // Delegated user-actor token (`tr_uat_…`): a short-lived, stateless // credential that authenticates as `userId`. `client` records what minted // it (e.g. a dashboard agent) for attribution. @@ -110,29 +143,91 @@ export interface RbacAbility { * which auth path serves the request — two copies of this grammar would * drift, and the difference would silently change what a token grants. */ +function parseScope(scope: string): { action: string; type?: string; id?: string } | undefined { + // Only the first two colons are delimiters — everything after the + // second colon is the resource id (which may itself contain colons, + // e.g. user-provided tags like "env:staging"). Naive + // `split(":")` + 3-tuple destructuring truncates such ids. + const parts = scope.split(":"); + const action = parts[0]; + if (!action) return undefined; + + return { + action, + type: parts[1] || undefined, + id: parts.length > 2 ? parts.slice(2).join(":") || undefined : undefined, + }; +} + +/** Only exact bare `admin` represents unrestricted access. */ +export function scopesGrantFullAccess(scopes: readonly string[]): boolean { + return scopes.includes("admin"); +} + +/** + * The one preset every install supports, including those with no preset + * catalogue at all. `prepareApiKeyPolicy` takes a required `presetId`, so + * callers that want a root-key-equivalent credential name this rather than + * relying on a default — see the note on that method. + */ +export const FULL_ACCESS_PRESET_ID = "FULL_ACCESS"; + +// The closed vocabulary a public token / API-key scope can address. This is +// the ONE place the `action:type[:id]` string grammar's terms are enumerated, +// shared by the scope *generator* (a plugin's preset builder) and the host's +// scope *checks* so the two cannot drift on a rename/typo. Every value here is +// already public: it appears in the OSS webapp's route authorization +// declarations and in `buildJwtAbility`'s grammar. A plugin that gates +// resources beyond this set can widen the union locally — do not add +// non-public terms here. +export type RbacScopeAction = "read" | "write" | "trigger" | "batchTrigger"; + +export type RbacScopeResourceType = + | "runs" + | "tasks" + | "batch" + | "queues" + | "deployments" + | "envvars" + | "apiKeys" + | "sessions" + | "waitpoints" + | "tags" + | "query"; + +/** + * Builds a single `action:type[:id]` scope string from typed parts. Scope + * generators should construct every scope through this helper so the shared + * `RbacScopeAction` / `RbacScopeResourceType` unions catch a mistyped or + * renamed term at compile time — the drift the grammar comment above warns + * about. `id` may itself contain colons (e.g. a tag like `env:staging`); it is + * appended verbatim and decoded by `parseScope`'s slice-join. + */ +export function buildScope( + action: RbacScopeAction, + type: RbacScopeResourceType, + id?: string +): string { + return id ? `${action}:${type}:${id}` : `${action}:${type}`; +} + export function buildJwtAbility(scopes: string[]): RbacAbility { const matches = (action: string, r: RbacResource): boolean => scopes.some((scope) => { - // Only the first two colons are delimiters — everything after the - // second colon is the resource id (which may itself contain colons, - // e.g. user-provided tags like "env:staging"). Naive - // `split(":")` + 3-tuple destructuring truncated such ids to the - // first segment and silently failed to match. - const parts = scope.split(":"); - const scopeAction = parts[0]; - const scopeType = parts[1]; - const scopeId = parts.length > 2 ? parts.slice(2).join(":") : undefined; + const parsed = parseScope(scope); + if (!parsed) return false; + // Bare `admin` is the universal wildcard. `admin:` is *not* — // it falls through to normal matching as action="admin" against // resources of that type. Treating `admin:` as universal // would silently broaden any such tokens beyond the narrow, // route-listed grant they had before scope-based abilities. - if (scopeAction === "admin" && !scopeType) return true; - if (scopeAction !== action && scopeAction !== "*") return false; - if (scopeType === "all") return true; - if (scopeType !== r.type) return false; - if (!scopeId) return true; - return scopeId === r.id; + if (parsed.action === "admin" && !parsed.type) return true; + if (parsed.action !== action && parsed.action !== "*") return false; + if (parsed.type === "all") return true; + if (parsed.type !== r.type) return false; + if (!parsed.id) return true; + return parsed.id === r.id; }); return { can(action: string, resource: RbacResource | RbacResource[]): boolean { @@ -148,6 +243,30 @@ export function buildJwtAbility(scopes: string[]): RbacAbility { }; } +/** + * Checks requested public-token scopes against an already-authenticated ability. + * Scope parsing intentionally lives beside buildJwtAbility so minting and + * validation use the same action:type[:id] grammar. + */ +export function scopesWithinAbility( + scopes: string[], + ability: RbacAbility +): { ok: boolean; deniedScopes: string[] } { + const deniedScopes = scopes.filter((scope) => { + const parsed = parseScope(scope); + if (!parsed || (!parsed.type && parsed.action !== "admin")) { + return true; + } + + return !ability.can(parsed.action, { + type: parsed.type ?? "all", + ...(parsed.id ? { id: parsed.id } : {}), + }); + }); + + return { ok: deniedScopes.length === 0, deniedScopes }; +} + // ── Delegated user-actor token grammar ─────────────────────────────────── // // A `tr_uat_…` token is the JWT body (signed HS256 with the platform secret) @@ -348,6 +467,41 @@ export interface RoleBaseAccessController { // an upgrade badge or hide them. systemRoles(organizationId: string): Promise; + // Plugin-owned API-key policy catalogue and policy generation. A null + // catalogue means no plugin is installed. The host owns credentials and + // persists the effective policy returned by prepareApiKeyPolicy(). + // + // These three are OPTIONAL, and are the first optional members of this + // interface — the distinction is deliberate. Methods the host cannot serve a + // request without (authenticateBearer, authenticatePat, authenticateSession) + // stay required. Capability extensions the host can degrade past are + // optional, so a plugin built against an older contract still satisfies this + // interface. That matters because the plugin is compiled against whichever + // OSS commit its base image carries: making an additive capability required + // turns every such addition into a lockstep two-repo merge, and turns a + // plugin rollback into an image build failure instead of a graceful + // degradation. + // + // Absence must fail CLOSED, and each default is chosen so it does: + // - apiKeyPresets -> null (identical to "no plugin installed") + // - prepareApiKeyPolicy -> { ok: false } — NEVER a full-access default; + // key creation stops, while already-issued keys + // keep authorizing from their persisted scopes + // - describeApiKeyPolicy -> {} (presentation only) + // LazyController applies these defaults, so host callers going through + // `rbac` see a total surface and cannot forget the guard. + apiKeyPresets?(organizationId: string): Promise; + // `presetId` is deliberately required: an authorization function must not + // have an implicit default, least of all a full-access one. A caller that + // omits the field should fail to compile rather than silently mint an admin + // credential. Installs with no preset catalogue pass "FULL_ACCESS". + prepareApiKeyPolicy?(params: { + organizationId: string; + presetId: string; + taskIdentifiers?: string[]; + }): Promise; + describeApiKeyPolicy?(policy: ApiKeyPolicy): Promise; + // Role introspection. The fallback returns []; a plugin may return // its own role catalogue. allPermissions(organizationId: string): Promise;