From 2ef3459557e735e1833b4062087ee12b09a199ec Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 12:51:12 +0100 Subject: [PATCH 01/11] fix(core,sdk): correct public token expirationTime docs A number passed to `expirationTime` is a Unix timestamp in seconds, not milliseconds as the JSDoc claimed. Following the old docs produced a token that effectively never expired. Also fail loudly when an additional API key reaches a local self-signing fallback. Those keys are not the environment's JWT signing material, so the token would never verify. Every endpoint that returns a public access token sets `x-trigger-jwt`, so this is unreachable today. --- .changeset/public-token-expiration-seconds.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/public-token-expiration-seconds.md diff --git a/.changeset/public-token-expiration-seconds.md b/.changeset/public-token-expiration-seconds.md new file mode 100644 index 00000000000..5eba6ba689b --- /dev/null +++ b/.changeset/public-token-expiration-seconds.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Correct the `expirationTime` docs on `auth.createPublicToken` and the trigger-token helpers: a number is a Unix timestamp in seconds, not milliseconds. From c980dd1957ff4787f5e8a17b54c70c57f9cf82f9 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 11:28:24 +0100 Subject: [PATCH 02/11] 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 cb6df265573..f04163b8594 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 dda45552bd8cecb92b1b52f9a92203e7205f30ce Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 13:10:07 +0100 Subject: [PATCH 03/11] 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 7be6df255c14fe61cab8cf1a8d0487168f174cb6 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 16:23:31 +0100 Subject: [PATCH 04/11] 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 20d2b7350f44f7448d35de1a696b4ae58a752aa2 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 17:39:40 +0100 Subject: [PATCH 05/11] 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 f04163b8594..af43abe9b76 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 735283b73fcf0529f76c0225821ce5e96c17998e Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 28 Jul 2026 12:18:34 +0100 Subject: [PATCH 06/11] 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 af43abe9b76..d3b3f96584a 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 f617cab16f3..9370c4023db 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -25,6 +25,10 @@ export const FEATURE_FLAG = { runOpsMintKindPrev: "runOpsMintKindPrev", runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt", queueMetricsUiEnabled: "queueMetricsUiEnabled", + // 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", @@ -78,9 +82,10 @@ export const FeatureFlagCatalog = { // Per-org access to the Queue Metrics dashboard UI (view only; emission is global and // separate). Off unless enabled for the org. [FEATURE_FLAG.queueMetricsUiEnabled]: z.coerce.boolean(), - // 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(), }; @@ -100,7 +105,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 c423c4fc429f65dfc1f2aacf7a25598d34b7909b Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 28 Jul 2026 12:32:15 +0100 Subject: [PATCH 07/11] 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"]) From 68d26b0cab4a0e7b5cc6e676d80f5145871880ca Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 4 Aug 2026 14:29:38 +0100 Subject: [PATCH 08/11] fixes for review comments --- .../presenters/v3/ApiKeysPresenter.server.ts | 2 +- .../route.tsx | 60 +------- .../additionalApiKeyIssuance.server.ts | 2 +- apps/webapp/app/services/apiAuth.server.ts | 10 +- .../services/apiKeyPresetValidation.server.ts | 59 ++++++++ .../environmentVariableApiAccess.server.ts | 17 ++- apps/webapp/test/apiAuthScope.test.ts | 143 +++++------------- .../test/environmentVariableApiAccess.test.ts | 87 ++++------- 8 files changed, 146 insertions(+), 234 deletions(-) create mode 100644 apps/webapp/app/services/apiKeyPresetValidation.server.ts diff --git a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts index ce562edfd06..c9bdbe7af6a 100644 --- a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts @@ -69,7 +69,7 @@ export class ApiKeysPresenter { const keyEnvironmentId = environment.parentEnvironmentId ?? environment.id; const [keyEnvironment, vercelIntegration] = await Promise.all([ - this.#prismaClient.runtimeEnvironment.findUniqueOrThrow({ + this.#prismaClient.runtimeEnvironment.findFirstOrThrow({ where: { id: keyEnvironmentId }, select: { id: true, 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 d3b3f96584a..815718efa1c 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 @@ -75,8 +75,11 @@ 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 { FULL_ACCESS_PRESET_ID } from "@trigger.dev/rbac"; import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server"; +import { + validateCreateApiKeyPreset, + type ApiKeyPreset, +} from "~/services/apiKeyPresetValidation.server"; import { rbac } from "~/services/rbac.server"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; @@ -88,8 +91,6 @@ 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), @@ -115,59 +116,6 @@ const ApiKeyActionSchema = z.discriminatedUnion("action", [ 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 }; diff --git a/apps/webapp/app/services/additionalApiKeyIssuance.server.ts b/apps/webapp/app/services/additionalApiKeyIssuance.server.ts index c5cf56bf6d7..f589069b885 100644 --- a/apps/webapp/app/services/additionalApiKeyIssuance.server.ts +++ b/apps/webapp/app/services/additionalApiKeyIssuance.server.ts @@ -10,7 +10,7 @@ export async function canIssueAdditionalApiKeys( prismaClient: IssuancePrismaClient = prisma ): Promise { const [organization, globalFlags] = await Promise.all([ - prismaClient.organization.findUnique({ + prismaClient.organization.findFirst({ where: { id: organizationId }, select: { featureFlags: true }, }), diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 90fc9d2d3b7..ba27195a108 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -299,7 +299,9 @@ export async function authenticateApiKeyWithScope( action, resource, allowJWT = false, - }: { action: string; resource: RbacResource; allowJWT?: boolean } + }: { action: string; resource: RbacResource; allowJWT?: boolean }, + authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = + authenticateAuthorizeBearerWithTelemetry ): Promise< | { ok: true; authentication: ApiAuthenticationResultSuccess } | { ok: false; status: 401 | 403; error: string } @@ -309,11 +311,7 @@ export async function authenticateApiKeyWithScope( return { ok: false, status: 401, error: "Invalid or Missing API key" }; } - const result = await authenticateAuthorizeBearerWithTelemetry( - request, - { action, resource }, - { allowJWT } - ); + const result = await authorizeBearer(request, { action, resource }, { allowJWT }); if (!result.ok) { return result; } diff --git a/apps/webapp/app/services/apiKeyPresetValidation.server.ts b/apps/webapp/app/services/apiKeyPresetValidation.server.ts new file mode 100644 index 00000000000..5b5acbfbe8b --- /dev/null +++ b/apps/webapp/app/services/apiKeyPresetValidation.server.ts @@ -0,0 +1,59 @@ +import { FULL_ACCESS_PRESET_ID } from "@trigger.dev/rbac"; + +export type ApiKeyPreset = { + id: string; + available: boolean; + label: string; + description: string; + usesTaskSelection?: boolean; +}; + +export function validateCreateApiKeyPreset({ + presets, + presetId, + taskScope, + taskIdentifiers, + hasTaskParameters, +}: { + presets: ApiKeyPreset[] | null; + presetId?: string; + taskScope?: "all" | "selected"; + taskIdentifiers: string[]; + hasTaskParameters: boolean; +}): { presetId: string; usesTaskSelection: boolean } { + 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 }; +} diff --git a/apps/webapp/app/services/environmentVariableApiAccess.server.ts b/apps/webapp/app/services/environmentVariableApiAccess.server.ts index e9fe2ef781c..641c9ad8534 100644 --- a/apps/webapp/app/services/environmentVariableApiAccess.server.ts +++ b/apps/webapp/app/services/environmentVariableApiAccess.server.ts @@ -32,12 +32,18 @@ export function presentedApiKeyFromAuthentication( * Keep PAT/OAT authentication on the legacy path while routing machine API * keys through the RBAC controller, where plugin grants are applied. */ +type AuthenticationDependencies = { + authenticateRequest: typeof authenticateRequest; + authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope; +}; + export async function authenticateEnvironmentScopedApiRequest( request: Request, action: "read" | "write", - resource: EnvironmentScopedResource + resource: EnvironmentScopedResource, + dependencies: AuthenticationDependencies = { authenticateRequest, authenticateApiKeyWithScope } ): Promise { - const userOrOrganizationAuthentication = await authenticateRequest(request, { + const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, { personalAccessToken: true, organizationAccessToken: true, apiKey: false, @@ -46,7 +52,7 @@ export async function authenticateEnvironmentScopedApiRequest( return { ok: true, authentication: userOrOrganizationAuthentication }; } - const apiKeyAuthentication = await authenticateApiKeyWithScope(request, { + const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, { action, resource: { type: resource }, }); @@ -63,9 +69,10 @@ export async function authenticateEnvironmentScopedApiRequest( /** Env var API routes: PAT/OAT on the legacy path, machine keys via RBAC. */ export function authenticateEnvVarApiRequest( request: Request, - action: "read" | "write" + action: "read" | "write", + dependencies?: AuthenticationDependencies ): Promise { - return authenticateEnvironmentScopedApiRequest(request, action, "envvars"); + return authenticateEnvironmentScopedApiRequest(request, action, "envvars", dependencies); } const RESOURCE_LABELS: Record = { diff --git a/apps/webapp/test/apiAuthScope.test.ts b/apps/webapp/test/apiAuthScope.test.ts index ab3d12c273c..7dadd9e2997 100644 --- a/apps/webapp/test/apiAuthScope.test.ts +++ b/apps/webapp/test/apiAuthScope.test.ts @@ -1,124 +1,65 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const rbacMocks = vi.hoisted(() => ({ - authenticateBearer: vi.fn<(...args: any[]) => Promise>(), -})); - -const telemetryMocks = vi.hoisted(() => ({ - attemptsAdd: vi.fn(), - durationRecord: vi.fn(), -})); - -vi.mock("@internal/tracing", () => ({ - getMeter: () => ({ - createCounter: () => ({ add: telemetryMocks.attemptsAdd }), - createHistogram: () => ({ record: telemetryMocks.durationRecord }), - createObservableGauge: () => ({ addCallback: vi.fn() }), - }), -})); -vi.mock("~/services/rbac.server", () => ({ rbac: rbacMocks })); -vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); -vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } })); -vi.mock("~/models/project.server", () => ({ findProjectByRef: vi.fn() })); -vi.mock("~/models/runtimeEnvironment.server", () => ({ - authIncludeBase: {}, - authIncludeWithParent: {}, - findEnvironmentByApiKey: vi.fn(), - findEnvironmentByPublicApiKey: vi.fn(), - toAuthenticated: vi.fn(), -})); -vi.mock("~/services/personalAccessToken.server", () => ({ - authenticateApiRequestWithPersonalAccessToken: vi.fn(), - isPersonalAccessToken: () => false, -})); -vi.mock("~/services/organizationAccessToken.server", () => ({ - authenticateApiRequestWithOrganizationAccessToken: vi.fn(), - isOrganizationAccessToken: () => false, -})); -vi.mock("~/services/realtime/jwtAuth.server", () => ({ - isPublicJWT: () => false, - validatePublicJwtKey: vi.fn(), -})); -vi.mock("~/services/logger.server", () => ({ - logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() }, -})); - +import { describe, expect, it, vi } from "vitest"; import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; -describe("authenticateApiKeyWithScope", () => { - beforeEach(() => { - rbacMocks.authenticateBearer.mockReset(); - telemetryMocks.attemptsAdd.mockReset(); - telemetryMocks.durationRecord.mockReset(); - }); +const authorizeBearer = vi.fn(); +describe("authenticateApiKeyWithScope", () => { it("returns 401 without a bearer credential", async () => { - const result = await authenticateApiKeyWithScope(new Request("https://example.com"), { - action: "read", - resource: { type: "envvars" }, - }); + const result = await authenticateApiKeyWithScope( + new Request("https://example.com"), + { action: "read", resource: { type: "envvars" } }, + authorizeBearer + ); expect(result).toEqual({ ok: false, status: 401, error: "Invalid or Missing API key", }); - expect(rbacMocks.authenticateBearer).not.toHaveBeenCalled(); + expect(authorizeBearer).not.toHaveBeenCalled(); }); it.each([ { status: 401 as const, error: "Invalid API key" }, { status: 403 as const, error: "Unauthorized" }, ])("preserves controller $status failures", async (failure) => { - rbacMocks.authenticateBearer.mockResolvedValue({ ok: false, ...failure }); + authorizeBearer.mockResolvedValueOnce({ ok: false, ...failure }); const request = new Request("https://example.com", { headers: { Authorization: "Bearer tr_test_key" }, }); await expect( - authenticateApiKeyWithScope(request, { - action: "write", - resource: { type: "deployments" }, - }) + authenticateApiKeyWithScope( + request, + { action: "write", resource: { type: "deployments" } }, + authorizeBearer + ) ).resolves.toEqual({ ok: false, ...failure }); }); it("bridges controller success into the legacy private authentication shape", async () => { const environment = { id: "env_123" }; const ability = { can: vi.fn(() => true), canSuper: vi.fn(() => true) }; - rbacMocks.authenticateBearer.mockResolvedValue({ + authorizeBearer.mockResolvedValueOnce({ ok: true, environment, ability, subject: { type: "apiKey", apiKeyId: "key_123" }, - resolution: { credentialKind: "root_api_key", lookupPath: "root_current" }, }); const request = new Request("https://example.com", { - headers: { Authorization: "Bearer tr_test_key", "x-trigger-branch": "feature/test" }, + headers: { Authorization: "Bearer tr_test_key" }, }); - const result = await authenticateApiKeyWithScope(request, { - action: "read", - resource: { type: "envvars" }, - allowJWT: true, - }); + const result = await authenticateApiKeyWithScope( + request, + { action: "read", resource: { type: "envvars" }, allowJWT: true }, + authorizeBearer + ); - expect(rbacMocks.authenticateBearer).toHaveBeenCalledWith(request, { allowJWT: true }); - expect(ability.can).toHaveBeenCalledWith("read", { type: "envvars" }); - expect(telemetryMocks.attemptsAdd).toHaveBeenCalledWith(1, { - resolver: "rbac", - credential_kind: "root_api_key", - result: "success", - lookup_path: "root_current", - }); - expect(telemetryMocks.durationRecord).toHaveBeenCalledWith( - expect.any(Number), - expect.objectContaining({ - resolver: "rbac", - credential_kind: "root_api_key", - result: "success", - lookup_path: "root_current", - }) + expect(authorizeBearer).toHaveBeenCalledWith( + request, + { action: "read", resource: { type: "envvars" } }, + { allowJWT: true } ); expect(result).toEqual({ ok: true, @@ -132,32 +73,24 @@ describe("authenticateApiKeyWithScope", () => { }); }); - it("records successful authentication before returning an authorization failure", async () => { + it("returns authorization failures from the controller", async () => { const ability = { can: vi.fn(() => false), canSuper: vi.fn(() => false) }; - rbacMocks.authenticateBearer.mockResolvedValue({ - ok: true, - environment: { id: "env_123" }, - ability, - subject: { type: "apiKey", apiKeyId: "key_123" }, - resolution: { credentialKind: "additional_api_key", lookupPath: "additional" }, + authorizeBearer.mockResolvedValueOnce({ + ok: false, + status: 403, + error: "Unauthorized", }); const request = new Request("https://example.com", { - headers: { Authorization: "Bearer tr_prod_sk_0123456789abcdefghijklmn" }, + headers: { Authorization: "Bearer tr_prod_sk_test" }, }); await expect( - authenticateApiKeyWithScope(request, { - action: "write", - resource: { type: "deployments" }, - }) + authenticateApiKeyWithScope( + request, + { action: "write", resource: { type: "deployments" } }, + authorizeBearer + ) ).resolves.toEqual({ ok: false, status: 403, error: "Unauthorized" }); - - expect(ability.can).toHaveBeenCalledWith("write", { type: "deployments" }); - expect(telemetryMocks.attemptsAdd).toHaveBeenCalledWith(1, { - resolver: "rbac", - credential_kind: "additional_api_key", - result: "success", - lookup_path: "additional", - }); + expect(ability.can).not.toHaveBeenCalled(); }); }); diff --git a/apps/webapp/test/environmentVariableApiAccess.test.ts b/apps/webapp/test/environmentVariableApiAccess.test.ts index 703df55895a..e148bdb49a6 100644 --- a/apps/webapp/test/environmentVariableApiAccess.test.ts +++ b/apps/webapp/test/environmentVariableApiAccess.test.ts @@ -1,20 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const authMocks = vi.hoisted(() => ({ - authenticateRequest: vi.fn<(...args: any[]) => Promise>(), - authenticateApiKeyWithScope: vi.fn<(...args: any[]) => Promise>(), -})); - -vi.mock("~/services/apiAuth.server", () => authMocks); -vi.mock("~/services/rbac.server", () => ({ - rbac: { authenticatePat: vi.fn(), authenticateUserActor: vi.fn() }, -})); - +import { describe, expect, it, vi } from "vitest"; import { authenticateEnvVarApiRequest, presentedApiKeyFromAuthentication, } from "~/services/environmentVariableApiAccess.server"; +const authenticateRequest = vi.fn(); +const authenticateApiKeyWithScope = vi.fn(); +const dependencies = { authenticateRequest, authenticateApiKeyWithScope }; + describe("presentedApiKeyFromAuthentication", () => { it("returns the API key that authenticated the request", () => { expect( @@ -24,7 +17,7 @@ describe("presentedApiKeyFromAuthentication", () => { ok: true, apiKey: "tr_prod_sk_presented", type: "PRIVATE", - environment: {} as never, + environment: {}, }, }) ).toBe("tr_prod_sk_presented"); @@ -34,78 +27,52 @@ describe("presentedApiKeyFromAuthentication", () => { expect( presentedApiKeyFromAuthentication({ type: "personalAccessToken", - result: { userId: "user_123" } as never, + result: { userId: "user_123" }, }) ).toBeUndefined(); }); }); describe("authenticateEnvVarApiRequest", () => { - beforeEach(() => { - authMocks.authenticateRequest.mockReset(); - authMocks.authenticateApiKeyWithScope.mockReset(); - }); - - it.each([ - { type: "personalAccessToken", result: { userId: "user_123" } }, - { type: "organizationAccessToken", result: { organizationId: "org_123" } }, - ])("preserves $type authentication", async (authentication) => { - authMocks.authenticateRequest.mockResolvedValue(authentication); - const request = new Request("https://example.com", { - headers: { Authorization: "Bearer token" }, - }); + it("keeps PAT authentication on the legacy path", async () => { + const authentication = { type: "personalAccessToken", result: { userId: "user_123" } } as never; + authenticateRequest.mockResolvedValueOnce(authentication); - await expect(authenticateEnvVarApiRequest(request, "read")).resolves.toEqual({ - ok: true, - authentication, - }); - expect(authMocks.authenticateRequest).toHaveBeenCalledWith(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: false, - }); - expect(authMocks.authenticateApiKeyWithScope).not.toHaveBeenCalled(); + await expect( + authenticateEnvVarApiRequest(new Request("https://example.com"), "read", dependencies) + ).resolves.toEqual({ ok: true, authentication }); + expect(authenticateApiKeyWithScope).not.toHaveBeenCalled(); }); - it("routes API-key credentials through scoped controller authentication", async () => { + it("uses scoped API-key authentication when no PAT is present", async () => { + authenticateRequest.mockResolvedValueOnce(undefined); const authentication = { ok: true, - apiKey: "tr_test_key", + apiKey: "tr_prod_sk_presented", type: "PRIVATE", - environment: { id: "env_123" }, - ability: { can: vi.fn(() => true) }, + environment: {}, }; - authMocks.authenticateRequest.mockResolvedValue(undefined); - authMocks.authenticateApiKeyWithScope.mockResolvedValue({ ok: true, authentication }); - const request = new Request("https://example.com", { - headers: { Authorization: "Bearer tr_test_key" }, - }); + authenticateApiKeyWithScope.mockResolvedValueOnce({ ok: true, authentication }); - await expect(authenticateEnvVarApiRequest(request, "write")).resolves.toEqual({ - ok: true, - authentication: { type: "apiKey", result: authentication }, - }); - expect(authMocks.authenticateApiKeyWithScope).toHaveBeenCalledWith(request, { + await expect( + authenticateEnvVarApiRequest(new Request("https://example.com"), "write", dependencies) + ).resolves.toEqual({ ok: true, authentication: { type: "apiKey", result: authentication } }); + expect(authenticateApiKeyWithScope).toHaveBeenCalledWith(expect.any(Request), { action: "write", resource: { type: "envvars" }, }); }); - it("preserves scoped controller failures", async () => { - authMocks.authenticateRequest.mockResolvedValue(undefined); - authMocks.authenticateApiKeyWithScope.mockResolvedValue({ + it("preserves scoped API-key failures", async () => { + authenticateRequest.mockResolvedValueOnce(undefined); + authenticateApiKeyWithScope.mockResolvedValueOnce({ ok: false, status: 403, error: "Unauthorized", }); await expect( - authenticateEnvVarApiRequest( - new Request("https://example.com", { - headers: { Authorization: "Bearer tr_test_key" }, - }), - "read" - ) + authenticateEnvVarApiRequest(new Request("https://example.com"), "read", dependencies) ).resolves.toEqual({ ok: false, status: 403, error: "Unauthorized" }); }); }); From 5f47b9234eb229c3922e47103a324294d03da861 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 4 Aug 2026 14:43:11 +0100 Subject: [PATCH 09/11] preseetId hardening --- .../route.tsx | 4 ++-- apps/webapp/app/services/apiKeyPresetValidation.server.ts | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) 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 815718efa1c..67bc98ddea2 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 @@ -101,7 +101,7 @@ const CreateApiKeySchema = z.object({ .refine((date) => date.getTime() > Date.now(), "Expiration must be in the future") .optional() ), - presetId: z.string().trim().min(1).optional(), + presetId: z.string().trim().min(1), taskScope: z.enum(["all", "selected"]).optional(), taskIdentifiers: z .array(z.string()) @@ -562,7 +562,7 @@ function NewApiKeyDialog({ const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [expiresAt, setExpiresAt] = useState(); - const defaultPresetId = presets?.find((preset) => preset.available)?.id ?? ""; + const defaultPresetId = presets?.find((preset) => preset.available)?.id ?? "FULL_ACCESS"; const [presetId, setPresetId] = useState(defaultPresetId); const [taskScope, setTaskScope] = useState<"all" | "selected">("all"); const [selectedTasks, setSelectedTasks] = useState([]); diff --git a/apps/webapp/app/services/apiKeyPresetValidation.server.ts b/apps/webapp/app/services/apiKeyPresetValidation.server.ts index 5b5acbfbe8b..aaf62a32f8b 100644 --- a/apps/webapp/app/services/apiKeyPresetValidation.server.ts +++ b/apps/webapp/app/services/apiKeyPresetValidation.server.ts @@ -24,17 +24,14 @@ export function validateCreateApiKeyPreset({ const fullAccess = { presetId: FULL_ACCESS_PRESET_ID, usesTaskSelection: false }; if (presets === null) { - if (presetId !== undefined || hasTaskParameters) { + if (presetId !== FULL_ACCESS_PRESET_ID || 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; + throw new Error("A preset is required"); } const preset = presets.find((candidate) => candidate.id === presetId); From 680b9c533e9a9c03cf435302c424ffce1f56a6c4 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 4 Aug 2026 14:51:53 +0100 Subject: [PATCH 10/11] format --- .../route.tsx | 5 ++++- apps/webapp/app/services/apiAuth.server.ts | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 67bc98ddea2..6d7e5de4f31 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 @@ -372,7 +372,10 @@ export default function Page() { ) : null} - + How to set these environment variables
diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index ba27195a108..6d7c1bd6f25 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -300,8 +300,7 @@ export async function authenticateApiKeyWithScope( resource, allowJWT = false, }: { action: string; resource: RbacResource; allowJWT?: boolean }, - authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = - authenticateAuthorizeBearerWithTelemetry + authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry ): Promise< | { ok: true; authentication: ApiAuthenticationResultSuccess } | { ok: false; status: 401 | 403; error: string } From c91b01d40bc6e37477445660be9720e907decacb Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 4 Aug 2026 15:31:40 +0100 Subject: [PATCH 11/11] review fixes --- .../route.tsx | 22 +++++++++++++------ apps/webapp/test/publicTokensRoute.test.ts | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) 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 6d7e5de4f31..284d9c8b96a 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 @@ -7,9 +7,9 @@ import { PlusIcon, } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { type MetaFunction, Form, useFetcher, useSearchParams } from "@remix-run/react"; +import { type MetaFunction, Form, useSearchParams } from "@remix-run/react"; import { useEffect, useState } from "react"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { CopyableText } from "~/components/primitives/CopyableText"; @@ -104,7 +104,7 @@ const CreateApiKeySchema = z.object({ presetId: z.string().trim().min(1), taskScope: z.enum(["all", "selected"]).optional(), taskIdentifiers: z - .array(z.string()) + .array(z.string().trim().min(1, "Task identifiers cannot be blank")) .max(MAX_API_KEY_TASK_IDENTIFIERS, { message: `You can select at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks`, }) @@ -560,8 +560,9 @@ function NewApiKeyDialog({ availableTasks: string[]; presets: ApiKeyPreset[] | null; }) { - const fetcher = useFetcher(); + const fetcher = useTypedFetcher(); const actionData = fetcher.data as ApiKeyActionData | undefined; + const [showError, setShowError] = useState(false); const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [expiresAt, setExpiresAt] = useState(); @@ -572,8 +573,14 @@ function NewApiKeyDialog({ const [createdApiKey, setCreatedApiKey] = useState(); useEffect(() => { - if (fetcher.state === "idle" && actionData?.ok && actionData.action === "create") { + if (fetcher.state !== "idle") { + return; + } + + if (actionData?.ok && actionData.action === "create") { setCreatedApiKey(actionData.apiKey); + } else if (actionData && !actionData.ok) { + setShowError(true); } }, [actionData, fetcher.state]); @@ -593,6 +600,7 @@ function NewApiKeyDialog({ setTaskScope("all"); setSelectedTasks([]); setCreatedApiKey(undefined); + setShowError(false); } }} > @@ -635,7 +643,7 @@ function NewApiKeyDialog({ />
) : ( - + setShowError(false)}> {expiresAt ? ( @@ -741,7 +749,7 @@ function NewApiKeyDialog({
) : null} - {actionData && !actionData.ok ? ( + {showError && actionData && !actionData.ok ? ( {actionData.error} diff --git a/apps/webapp/test/publicTokensRoute.test.ts b/apps/webapp/test/publicTokensRoute.test.ts index f946475882b..6a8a5a54f31 100644 --- a/apps/webapp/test/publicTokensRoute.test.ts +++ b/apps/webapp/test/publicTokensRoute.test.ts @@ -203,7 +203,7 @@ describe("POST /api/v1/auth/public-tokens", () => { // 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); + expect(exp).toBeLessThanOrEqual(before + 605); }); it("does not allow a public JWT bearer to mint another token", async () => {