From 3725c5623e2327cd98817fa8c01bbf3d167ff234 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 11:28:24 +0100 Subject: [PATCH 1/6] feat(webapp): add multiple environment API key management --- .../api-key-deploy-envvars-presets.md | 6 + .../public-token-additional-api-keys.md | 6 + apps/webapp/app/models/api-key.server.ts | 136 ++- .../presenters/v3/ApiKeysPresenter.server.ts | 166 +++- .../route.tsx | 927 +++++++++++++++--- .../app/routes/api.v1.auth.public-tokens.ts | 6 + .../app/services/publicTokens.server.ts | 123 +++ apps/webapp/test/apiKeysPresenter.test.ts | 201 ++++ .../test/createEnvironmentApiKey.test.ts | 255 +++++ apps/webapp/test/publicTokensRoute.test.ts | 269 +++++ 10 files changed, 1930 insertions(+), 165 deletions(-) create mode 100644 .server-changes/api-key-deploy-envvars-presets.md create mode 100644 .server-changes/public-token-additional-api-keys.md create mode 100644 apps/webapp/app/routes/api.v1.auth.public-tokens.ts create mode 100644 apps/webapp/app/services/publicTokens.server.ts create mode 100644 apps/webapp/test/apiKeysPresenter.test.ts create mode 100644 apps/webapp/test/createEnvironmentApiKey.test.ts create mode 100644 apps/webapp/test/publicTokensRoute.test.ts 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/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts index 19947417229..c2cc1577df5 100644 --- a/apps/webapp/app/models/api-key.server.ts +++ b/apps/webapp/app/models/api-key.server.ts @@ -1,9 +1,17 @@ -import type { RuntimeEnvironment } from "@trigger.dev/database"; -import { prisma } from "~/db.server"; +import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database"; +import type { RoleBaseAccessController } from "@trigger.dev/rbac"; +import { trail } from "agentcrumbs"; // @crumbs import { customAlphabet } from "nanoid"; +import { prisma } from "~/db.server"; import { RuntimeEnvironmentType } from "~/database-types"; +import { rbac } from "~/services/rbac.server"; +import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; +const crumb = trail("webapp"); // @crumbs + +export const MAX_API_KEY_TASK_IDENTIFIERS = 10; + const apiKeyId = customAlphabet( "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 12 @@ -94,8 +102,130 @@ 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, + }: { + prismaClient?: Pick; + rbacController?: Pick; + } = {} +) { + 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"); + } + + 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"); + } + } + + const prepared = await rbacController.prepareApiKeyPolicy({ + organizationId: environment.organizationId, + presetId, + taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined, + }); + + if (!prepared.ok) { + throw new Error(prepared.error); + } + + const generated = generateAdditionalApiKey(environment.type); + const apiKey = 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, + }, + }); + + 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; +}) { + const result = await prisma.apiKey.updateMany({ + where: { + id: apiKeyId, + runtimeEnvironmentId: environmentId, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + + if (result.count !== 1) { + throw new Error("API key not found or already revoked"); + } + + 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 846d2790322..e33d83e113e 100644 --- a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts @@ -1,67 +1,63 @@ import { type RuntimeEnvironment } from "@trigger.dev/database"; -import { type PrismaClient, prisma } from "~/db.server"; +import { scopesGrantFullAccess, type RoleBaseAccessController } 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< + RoleBaseAccessController, + "apiKeyPresets" | "describeApiKeyPolicy" +>; 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 +65,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 1d6b12bd2d0..001d5d81d2b 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,191 @@ 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 { + createEnvironmentApiKey, + MAX_API_KEY_TASK_IDENTIFIERS, + 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 { 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 }) => { try { const presenter = new ApiKeysPresenter(); - const { environment, hasVercelIntegration } = await presenter.call({ + const data = await presenter.call({ userId: user.id, - projectSlug: projectParam, - environmentSlug: envParam, + organizationSlug: params.organizationSlug, + projectSlug: params.projectParam, + environmentSlug: params.envParam, + showRevoked: searchParams.showRevoked, }); - 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, + showRevoked: searchParams.showRevoked ?? false, }); } catch (error) { console.error(error); @@ -89,21 +229,129 @@ 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 }); + } - if (!environment) { - throw new Response(undefined, { - status: 404, - statusText: "Environment not found", + 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." + ); + } + + 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); + } - let envBlock = `TRIGGER_SECRET_KEY="${environment.apiKey}"`; - if (environment.branchName) { - envBlock += `\nTRIGGER_PREVIEW_BRANCH="${environment.branchName}"`; + const keyEnvironmentId = environment.parentEnvironmentId ?? environment.id; + const returnPath = `${new URL(request.url).pathname}${new URL(request.url).search}`; + + try { + switch (submission.data.action) { + case "create": { + 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, + 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 +360,7 @@ export default function Page() { - + {environment.slug} @@ -121,106 +369,553 @@ export default function Page() { - + API keys docs + + {canReadApiKeys ? ( + + ) : 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 ?? + "Deleted user"; + + 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. + + + + Requires a version of @trigger.dev/sdk{" "} + that supports additional environment API keys. On older versions,{" "} + auth.createPublicToken() will return a + token the server rejects, because only the root API key can sign one locally. + + + + + } + /> +
+ ) : ( + + + {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 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/services/publicTokens.server.ts b/apps/webapp/app/services/publicTokens.server.ts new file mode 100644 index 00000000000..b61ab4560f5 --- /dev/null +++ b/apps/webapp/app/services/publicTokens.server.ts @@ -0,0 +1,123 @@ +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 { 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 +) { + // 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 { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const parsedBody = RequestBodySchema.safeParse(body); + if (!parsedBody.success) { + return json( + { error: "Invalid request body", issues: parsedBody.error.issues }, + { status: 400 } + ); + } + + const scopeCheck = scopesWithinAbility(parsedBody.data.scopes, authResult.ability); + if (!scopeCheck.ok) { + 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) { + 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) { + return json({ error: "Expiration time must be in the future" }, { status: 400 }); + } + if (expiresAt - now > MAX_PUBLIC_TOKEN_LIFETIME_SECONDS) { + return json({ error: "Expiration time cannot exceed 30 days" }, { status: 400 }); + } + + const 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, + }); + + return json({ token }); +} diff --git a/apps/webapp/test/apiKeysPresenter.test.ts b/apps/webapp/test/apiKeysPresenter.test.ts new file mode 100644 index 00000000000..f7547407449 --- /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_ak_••••••••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 00000000000..d97750798c0 --- /dev/null +++ b/apps/webapp/test/createEnvironmentApiKey.test.ts @@ -0,0 +1,255 @@ +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 { createEnvironmentApiKey, MAX_API_KEY_TASK_IDENTIFIERS } from "~/models/api-key.server"; +import { + createRuntimeEnvironment, + createTestOrgProjectWithMember, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; + +vi.setConfig({ testTimeout: 60_000 }); + +function policyController( + implementation: RoleBaseAccessController["prepareApiKeyPolicy"] +): Pick { + return { prepareApiKeyPolicy: vi.fn(implementation) }; +} + +async function setup(prisma: PrismaClient) { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + slug: uniqueId("prod"), + }); + return { organization, project, user, environment }; +} + +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 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 } + ); + + expect(result.plaintext).toMatch(/^tr_prod_ak_[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("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", + })); + + await expect( + createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Unavailable", + presetId: "RESTRICTED", + }, + { prismaClient: prisma, rbacController: controller } + ) + ).rejects.toThrow("not available on your plan"); + + 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 00000000000..afb5b82d1f8 --- /dev/null +++ b/apps/webapp/test/publicTokensRoute.test.ts @@ -0,0 +1,269 @@ +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 } from "vitest"; +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>; +} + +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("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 +); From 0151b741ec29f9eea270587a8c3fa4fa668b9b12 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 13:10:07 +0100 Subject: [PATCH 2/6] refactor(webapp): depend on the host RBAC controller surface The API key policy methods are optional on the plugin-facing controller contract, so `Pick` over it yields optional members that these call sites would have to guard. Both already receive the LazyController singleton, which has substituted its fail-closed defaults, so point them at HostRbacController and keep the call sites guard-free. --- apps/webapp/app/models/api-key.server.ts | 8 ++++++-- .../app/presenters/v3/ApiKeysPresenter.server.ts | 11 ++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts index c2cc1577df5..c8e9ff28181 100644 --- a/apps/webapp/app/models/api-key.server.ts +++ b/apps/webapp/app/models/api-key.server.ts @@ -1,5 +1,9 @@ import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database"; -import type { RoleBaseAccessController } from "@trigger.dev/rbac"; +// 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 { prisma } from "~/db.server"; @@ -128,7 +132,7 @@ export async function createEnvironmentApiKey( rbacController = rbac, }: { prismaClient?: Pick; - rbacController?: Pick; + rbacController?: Pick; } = {} ) { const environment = await prismaClient.runtimeEnvironment.findFirst({ diff --git a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts index e33d83e113e..ce562edfd06 100644 --- a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts @@ -1,15 +1,16 @@ import { type RuntimeEnvironment } from "@trigger.dev/database"; -import { scopesGrantFullAccess, type RoleBaseAccessController } from "@trigger.dev/rbac"; +// 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< - RoleBaseAccessController, - "apiKeyPresets" | "describeApiKeyPolicy" ->; +type ApiKeyPolicyPresenter = Pick; export class ApiKeysPresenter { // Read-only presenter for a dashboard page — all queries below are reads, so From 336d82419a6a9e3ebf7f7ede4f4c9c50245af37b Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 16:23:31 +0100 Subject: [PATCH 3/6] test(webapp): use _sk_ additional API key infix --- apps/webapp/test/apiKeysPresenter.test.ts | 2 +- apps/webapp/test/createEnvironmentApiKey.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/test/apiKeysPresenter.test.ts b/apps/webapp/test/apiKeysPresenter.test.ts index f7547407449..82ad8a5e4e5 100644 --- a/apps/webapp/test/apiKeysPresenter.test.ts +++ b/apps/webapp/test/apiKeysPresenter.test.ts @@ -126,7 +126,7 @@ containerTest( }); 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_ak_••••••••full"); + expect(keysByName.get("Full access")?.obfuscated).toBe("tr_prod_sk_••••••••full"); expect(keysByName.get("Full access")?.access).toMatchObject({ presetId: null, label: "Full access", diff --git a/apps/webapp/test/createEnvironmentApiKey.test.ts b/apps/webapp/test/createEnvironmentApiKey.test.ts index d97750798c0..76f916eb199 100644 --- a/apps/webapp/test/createEnvironmentApiKey.test.ts +++ b/apps/webapp/test/createEnvironmentApiKey.test.ts @@ -45,7 +45,7 @@ containerTest("standalone fallback creates one explicit full-access key", async { prismaClient: prisma, rbacController: fallback } ); - expect(result.plaintext).toMatch(/^tr_prod_ak_[A-Za-z0-9]{24}$/); + expect(result.plaintext).toMatch(/^tr_prod_sk_[A-Za-z0-9]{24}$/); expect(result.apiKey).toMatchObject({ presetId: null, scopes: ["admin"], From d8c02825dcb341b649672aaaeea7c0b768e972ab Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 17:39:40 +0100 Subject: [PATCH 4/6] fix(webapp): keep API key limit out of server-only module --- apps/webapp/app/consts.ts | 1 + apps/webapp/app/models/api-key.server.ts | 3 +-- .../route.tsx | 7 ++----- apps/webapp/test/createEnvironmentApiKey.test.ts | 3 ++- 4 files changed, 6 insertions(+), 8 deletions(-) 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 c8e9ff28181..af1f2302690 100644 --- a/apps/webapp/app/models/api-key.server.ts +++ b/apps/webapp/app/models/api-key.server.ts @@ -6,6 +6,7 @@ import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database"; 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 { rbac } from "~/services/rbac.server"; @@ -14,8 +15,6 @@ import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver. const crumb = trail("webapp"); // @crumbs -export const MAX_API_KEY_TASK_IDENTIFIERS = 10; - const apiKeyId = customAlphabet( "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 12 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 001d5d81d2b..3e6a6886e2f 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 @@ -63,11 +63,8 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { - createEnvironmentApiKey, - MAX_API_KEY_TASK_IDENTIFIERS, - revokeEnvironmentApiKey, -} from "~/models/api-key.server"; +import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts"; +import { createEnvironmentApiKey, revokeEnvironmentApiKey } from "~/models/api-key.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage, diff --git a/apps/webapp/test/createEnvironmentApiKey.test.ts b/apps/webapp/test/createEnvironmentApiKey.test.ts index 76f916eb199..399c531576c 100644 --- a/apps/webapp/test/createEnvironmentApiKey.test.ts +++ b/apps/webapp/test/createEnvironmentApiKey.test.ts @@ -2,7 +2,8 @@ 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 { createEnvironmentApiKey, MAX_API_KEY_TASK_IDENTIFIERS } from "~/models/api-key.server"; +import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts"; +import { createEnvironmentApiKey } from "~/models/api-key.server"; import { createRuntimeEnvironment, createTestOrgProjectWithMember, From ba81e5e6619983fb9aec57eae66ab8aa587d87a1 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 28 Jul 2026 12:18:34 +0100 Subject: [PATCH 5/6] feat(webapp): gate additional API key creation Require both the global issuance switch and organization rollout flag before creating additional keys, while leaving existing credentials available for use and revocation. Show nullable creators and identify SDK v4.5.8 as the first compatible public-token version. --- apps/webapp/app/models/api-key.server.ts | 15 ++++- .../route.tsx | 45 +++++++++----- .../additionalApiKeyIssuance.server.ts | 35 +++++++++++ .../app/services/additionalApiKeyIssuance.ts | 17 +++++ apps/webapp/app/v3/featureFlags.ts | 14 +++-- .../test/additionalApiKeyIssuance.test.ts | 62 +++++++++++++++++++ .../test/createEnvironmentApiKey.test.ts | 57 +++++++++++++++-- 7 files changed, 220 insertions(+), 25 deletions(-) create mode 100644 apps/webapp/app/services/additionalApiKeyIssuance.server.ts create mode 100644 apps/webapp/app/services/additionalApiKeyIssuance.ts create mode 100644 apps/webapp/test/additionalApiKeyIssuance.test.ts diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts index af1f2302690..4f44d3372a0 100644 --- a/apps/webapp/app/models/api-key.server.ts +++ b/apps/webapp/app/models/api-key.server.ts @@ -9,6 +9,7 @@ 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 { rbac } from "~/services/rbac.server"; import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; @@ -129,9 +130,14 @@ export async function createEnvironmentApiKey( { prismaClient = prisma, rbacController = rbac, + issuanceAllowed, }: { - prismaClient?: Pick; + prismaClient?: Pick< + PrismaClient, + "apiKey" | "featureFlag" | "organization" | "runtimeEnvironment" | "taskIdentifier" + >; rbacController?: Pick; + issuanceAllowed?: (organizationId: string) => Promise; } = {} ) { const environment = await prismaClient.runtimeEnvironment.findFirst({ @@ -146,6 +152,13 @@ export async function createEnvironmentApiKey( 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"); } 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 3e6a6886e2f..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 @@ -76,6 +76,7 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; 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"; @@ -180,16 +181,21 @@ export const loader = dashboardLoader( return organizationId ? { organizationId } : {}; }, }, - async ({ params, searchParams, user, ability }) => { + async ({ params, searchParams, user, ability, context }) => { try { const presenter = new ApiKeysPresenter(); - const data = await presenter.call({ - userId: user.id, - organizationSlug: params.organizationSlug, - projectSlug: params.projectParam, - environmentSlug: params.envParam, - showRevoked: searchParams.showRevoked, - }); + 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 = ability.can("read", { type: "apiKeys", @@ -214,6 +220,7 @@ export const loader = dashboardLoader( apiKeys: canReadApiKeys ? data.apiKeys : [], canReadApiKeys, canWriteApiKeys, + additionalApiKeyIssuanceEnabled, showRevoked: searchParams.showRevoked ?? false, }); } catch (error) { @@ -276,6 +283,15 @@ export const action = dashboardAction( 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, @@ -335,6 +351,7 @@ export default function Page() { apiKeys, canReadApiKeys, canWriteApiKeys, + additionalApiKeyIssuanceEnabled, showRevoked, hasVercelIntegration, availableTasks, @@ -370,7 +387,7 @@ export default function Page() { API keys docs - {canReadApiKeys ? ( + {canReadApiKeys && additionalApiKeyIssuanceEnabled ? ( @@ -653,10 +670,10 @@ function NewApiKeyDialog({ className="w-full" /> - Requires a version of @trigger.dev/sdk{" "} - that supports additional environment API keys. On older versions,{" "} - auth.createPublicToken() will return a - token the server rejects, because only the root API key can sign one locally. + 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. ; + +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/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index e90f25f7ea8..68fe7dfc41b 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 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/createEnvironmentApiKey.test.ts b/apps/webapp/test/createEnvironmentApiKey.test.ts index 399c531576c..a9487018c15 100644 --- a/apps/webapp/test/createEnvironmentApiKey.test.ts +++ b/apps/webapp/test/createEnvironmentApiKey.test.ts @@ -4,6 +4,7 @@ import rbacPlugin, { type RoleBaseAccessController } from "@trigger.dev/rbac"; import { expect, vi } from "vitest"; import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts"; import { createEnvironmentApiKey } from "~/models/api-key.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; import { createRuntimeEnvironment, createTestOrgProjectWithMember, @@ -20,15 +21,59 @@ function policyController( async function setup(prisma: PrismaClient) { const { organization, project, user } = await createTestOrgProjectWithMember(prisma); - const environment = await createRuntimeEnvironment(prisma, { - projectId: project.id, - organizationId: organization.id, - type: "PRODUCTION", - slug: uniqueId("prod"), - }); + 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 }); From ca744a5c8483cea298d6e079ace08bc4182813e8 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 28 Jul 2026 12:32:15 +0100 Subject: [PATCH 6/6] feat(webapp): add API key lifecycle metrics Record bounded outcomes for additional key creation, policy preparation, revocation, and public-token minting. --- apps/webapp/app/models/api-key.server.ts | 101 ++++++++++++------ .../app/services/apiKeyTelemetry.server.ts | 49 +++++++++ .../app/services/publicTokens.server.ts | 47 +++++--- .../test/createEnvironmentApiKey.test.ts | 48 ++++++++- apps/webapp/test/publicTokensRoute.test.ts | 35 +++++- 5 files changed, 228 insertions(+), 52 deletions(-) create mode 100644 apps/webapp/app/services/apiKeyTelemetry.server.ts diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts index 4f44d3372a0..c21df2fe650 100644 --- a/apps/webapp/app/models/api-key.server.ts +++ b/apps/webapp/app/models/api-key.server.ts @@ -10,6 +10,7 @@ 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"; @@ -131,6 +132,7 @@ export async function createEnvironmentApiKey( prismaClient = prisma, rbacController = rbac, issuanceAllowed, + telemetryRecorder = apiKeyTelemetry, }: { prismaClient?: Pick< PrismaClient, @@ -138,6 +140,7 @@ export async function createEnvironmentApiKey( >; rbacController?: Pick; issuanceAllowed?: (organizationId: string) => Promise; + telemetryRecorder?: ApiKeyTelemetry; } = {} ) { const environment = await prismaClient.runtimeEnvironment.findFirst({ @@ -184,29 +187,45 @@ export async function createEnvironmentApiKey( } } - const prepared = await rbacController.prepareApiKeyPolicy({ - organizationId: environment.organizationId, - presetId, - taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined, - }); + 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 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, - }, - }); + 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, @@ -217,26 +236,44 @@ export async function createEnvironmentApiKey( return { apiKey, plaintext: generated.apiKey }; } -export async function revokeEnvironmentApiKey({ - environmentId, - apiKeyId, -}: { - environmentId: string; - apiKeyId: string; -}) { - const result = await prisma.apiKey.updateMany({ - where: { - id: apiKeyId, - runtimeEnvironmentId: environmentId, - revokedAt: null, - }, - data: { revokedAt: new Date() }, - }); +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 } 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/publicTokens.server.ts b/apps/webapp/app/services/publicTokens.server.ts index b61ab4560f5..590a7f3b2c9 100644 --- a/apps/webapp/app/services/publicTokens.server.ts +++ b/apps/webapp/app/services/publicTokens.server.ts @@ -3,6 +3,7 @@ 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. @@ -53,7 +54,8 @@ function expirationTimestamp(expirationTime: string | number, now: number): numb export async function handlePublicTokenRequest( request: Request, - controller: Pick = rbac + 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); @@ -65,11 +67,13 @@ export async function handlePublicTokenRequest( 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 } @@ -78,6 +82,7 @@ export async function handlePublicTokenRequest( 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", @@ -92,32 +97,42 @@ export async function handlePublicTokenRequest( 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 }); } - const 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, - }); + 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/test/createEnvironmentApiKey.test.ts b/apps/webapp/test/createEnvironmentApiKey.test.ts index a9487018c15..3ebdc66b382 100644 --- a/apps/webapp/test/createEnvironmentApiKey.test.ts +++ b/apps/webapp/test/createEnvironmentApiKey.test.ts @@ -3,7 +3,8 @@ 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 } from "~/models/api-key.server"; +import { createEnvironmentApiKey, revokeEnvironmentApiKey } from "~/models/api-key.server"; +import type { ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server"; import { FEATURE_FLAG } from "~/v3/featureFlags"; import { createRuntimeEnvironment, @@ -19,6 +20,13 @@ function policyController( 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([ @@ -77,6 +85,7 @@ containerTest( 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( @@ -88,9 +97,11 @@ containerTest("standalone fallback creates one explicit full-access key", async expiresAt, presetId: "FULL_ACCESS", }, - { prismaClient: prisma, rbacController: fallback } + { 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, @@ -102,6 +113,31 @@ containerTest("standalone fallback creates one explicit full-access key", async ).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 () => ({ @@ -155,6 +191,7 @@ containerTest("policy preparation failure inserts no credential", async ({ prism ok: false, error: "This API key access preset is not available on your plan", })); + const telemetry = telemetryRecorder(); await expect( createEnvironmentApiKey( @@ -165,10 +202,15 @@ containerTest("policy preparation failure inserts no credential", async ({ prism name: "Unavailable", presetId: "RESTRICTED", }, - { prismaClient: prisma, rbacController: controller } + { 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); diff --git a/apps/webapp/test/publicTokensRoute.test.ts b/apps/webapp/test/publicTokensRoute.test.ts index afb5b82d1f8..f946475882b 100644 --- a/apps/webapp/test/publicTokensRoute.test.ts +++ b/apps/webapp/test/publicTokensRoute.test.ts @@ -3,7 +3,8 @@ 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 } from "vitest"; +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"; @@ -64,6 +65,13 @@ 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 [ @@ -88,6 +96,31 @@ describe("POST /api/v1/auth/public-tokens", () => { } }); + 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"])