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 0000000000..cd7a739e32 --- /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 0000000000..79b7f7d4a1 --- /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 e349bc086b..4bd070a5ba 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 1994741722..c21df2fe65 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/presenters/v3/ApiKeysPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts index 846d279032..ce562edfd0 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/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 1d6b12bd2d..f3e9e0cfe7 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.auth.public-tokens.ts b/apps/webapp/app/routes/api.v1.auth.public-tokens.ts new file mode 100644 index 0000000000..08def65fd7 --- /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/services/additionalApiKeyIssuance.server.ts b/apps/webapp/app/services/additionalApiKeyIssuance.server.ts new file mode 100644 index 0000000000..c5cf56bf6d --- /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 0000000000..380a146875 --- /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/apiKeyTelemetry.server.ts b/apps/webapp/app/services/apiKeyTelemetry.server.ts new file mode 100644 index 0000000000..3d4b80fe5a --- /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/publicTokens.server.ts b/apps/webapp/app/services/publicTokens.server.ts new file mode 100644 index 0000000000..590a7f3b2c --- /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/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index e90f25f7ea..68fe7dfc41 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -22,6 +22,10 @@ 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", @@ -64,9 +68,10 @@ export const FeatureFlagCatalog = { // by stampMintKindFlip on a genuine flip. Display-only (see ORG_LOCKED_FLAGS). [FEATURE_FLAG.runOpsMintKindPrev]: z.enum(["cuid", "runOpsId"]), [FEATURE_FLAG.runOpsMintKindFlippedAt]: z.string().datetime(), - // Strict z.boolean() (not z.coerce.boolean()): coercion turns the string - // "false" into true, which would silently enable this kill switch the wrong - // way if written as a string. Cold/absent resolves to the safe `false`. + // 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(), }; @@ -86,7 +91,8 @@ export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [ FEATURE_FLAG.taskEventRepository, FEATURE_FLAG.runOpsMintKindPrev, FEATURE_FLAG.runOpsMintKindFlippedAt, - // System-wide only — an org must not be able to override the rollout switch. + // System-wide only — orgs must not be able to override these kill switches. + FEATURE_FLAG.additionalApiKeyIssuanceEnabled, FEATURE_FLAG.additionalApiKeyLookupEnabled, ]; diff --git a/apps/webapp/test/additionalApiKeyIssuance.test.ts b/apps/webapp/test/additionalApiKeyIssuance.test.ts new file mode 100644 index 0000000000..57c3ca0f83 --- /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/apiKeysPresenter.test.ts b/apps/webapp/test/apiKeysPresenter.test.ts new file mode 100644 index 0000000000..82ad8a5e4e --- /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/createEnvironmentApiKey.test.ts b/apps/webapp/test/createEnvironmentApiKey.test.ts new file mode 100644 index 0000000000..3ebdc66b38 --- /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/publicTokensRoute.test.ts b/apps/webapp/test/publicTokensRoute.test.ts new file mode 100644 index 0000000000..f946475882 --- /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 +);