From af05aeab8e795a4f71348c687f1e50c15f8d268b Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 12:51:12 +0100 Subject: [PATCH 01/16] 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 0000000000..5eba6ba689 --- /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 e15a7893fc235b41d6e8dd775d787b3b7884d813 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 11:18:59 +0100 Subject: [PATCH 02/16] feat(database,rbac): add multiple environment API key foundations --- apps/webapp/app/utils/apiKeys.test.ts | 42 +++++ apps/webapp/app/utils/apiKeys.ts | 51 ++++++ .../migration.sql | 30 ++++ .../database/prisma/schema.prisma | 27 +++ internal-packages/rbac/src/ability.test.ts | 38 ++++ internal-packages/rbac/src/ability.ts | 2 +- internal-packages/rbac/src/fallback.ts | 34 +++- internal-packages/rbac/src/index.ts | 16 ++ packages/core/src/v3/jwt.ts | 34 ++++ packages/plugins/src/index.ts | 14 +- packages/plugins/src/rbac.ts | 164 ++++++++++++++++-- 11 files changed, 434 insertions(+), 18 deletions(-) create mode 100644 apps/webapp/app/utils/apiKeys.test.ts create mode 100644 apps/webapp/app/utils/apiKeys.ts create mode 100644 internal-packages/database/prisma/migrations/20260723112558_add_environment_api_keys/migration.sql diff --git a/apps/webapp/app/utils/apiKeys.test.ts b/apps/webapp/app/utils/apiKeys.test.ts new file mode 100644 index 0000000000..288676634a --- /dev/null +++ b/apps/webapp/app/utils/apiKeys.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { + apiKeyPrefix, + generateAdditionalApiKey, + generateRootApiKey, + hashApiKey, + obfuscateApiKey, +} from "./apiKeys"; + +describe("API key utilities", () => { + test.each([ + ["DEVELOPMENT", "tr_dev_"], + ["STAGING", "tr_stg_"], + ["PRODUCTION", "tr_prod_"], + ["PREVIEW", "tr_preview_"], + ] as const)("generates %s keys", (environmentType, prefix) => { + const root = generateRootApiKey(environmentType); + const additional = generateAdditionalApiKey(environmentType); + + expect(root.apiKey).toMatch(new RegExp(`^${prefix}[A-Za-z0-9]{24}$`)); + expect(root.keyHash).toBe(hashApiKey(root.apiKey)); + expect(root.lastFour).toBe(root.apiKey.slice(-4)); + expect(additional.apiKey).toMatch(new RegExp(`^${prefix}ak_[A-Za-z0-9]{24}$`)); + expect(additional.keyHash).toBe(hashApiKey(additional.apiKey)); + expect(additional.lastFour).toBe(additional.apiKey.slice(-4)); + expect(apiKeyPrefix(environmentType)).toBe(prefix); + expect(obfuscateApiKey(environmentType, root.lastFour)).toBe( + `${prefix}••••••••${root.lastFour}` + ); + expect(obfuscateApiKey(environmentType, additional.lastFour, "additional")).toBe( + `${prefix}ak_••••••••${additional.lastFour}` + ); + }); + + test("generates unique keys", () => { + const keys = new Set( + Array.from({ length: 100 }, () => generateAdditionalApiKey("PRODUCTION").apiKey) + ); + + expect(keys.size).toBe(100); + }); +}); diff --git a/apps/webapp/app/utils/apiKeys.ts b/apps/webapp/app/utils/apiKeys.ts new file mode 100644 index 0000000000..02464b4c78 --- /dev/null +++ b/apps/webapp/app/utils/apiKeys.ts @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { customAlphabet } from "nanoid"; + +const apiKeyId = customAlphabet( + "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + 24 +); + +export function hashApiKey(apiKey: string): string { + return createHash("sha256").update(apiKey, "utf8").digest("hex"); +} + +function generatedApiKey(apiKey: string) { + return { + apiKey, + keyHash: hashApiKey(apiKey), + lastFour: apiKey.slice(-4), + }; +} + +export function generateRootApiKey(environmentType: RuntimeEnvironmentType) { + // Root keys intentionally use the same 24-character entropy as additional keys. + return generatedApiKey(`${apiKeyPrefix(environmentType)}${apiKeyId()}`); +} + +export function generateAdditionalApiKey(environmentType: RuntimeEnvironmentType) { + return generatedApiKey(`${apiKeyPrefix(environmentType)}ak_${apiKeyId()}`); +} + +export function apiKeyPrefix(environmentType: RuntimeEnvironmentType): string { + switch (environmentType) { + case "DEVELOPMENT": + return "tr_dev_"; + case "STAGING": + return "tr_stg_"; + case "PRODUCTION": + return "tr_prod_"; + case "PREVIEW": + return "tr_preview_"; + } +} + +export function obfuscateApiKey( + environmentType: RuntimeEnvironmentType, + lastFour: string, + kind: "root" | "additional" = "root" +): string { + const discriminator = kind === "additional" ? "ak_" : ""; + return `${apiKeyPrefix(environmentType)}${discriminator}••••••••${lastFour}`; +} diff --git a/internal-packages/database/prisma/migrations/20260723112558_add_environment_api_keys/migration.sql b/internal-packages/database/prisma/migrations/20260723112558_add_environment_api_keys/migration.sql new file mode 100644 index 0000000000..63957b702d --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260723112558_add_environment_api_keys/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "public"."api_keys" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "key_hash" TEXT NOT NULL, + "last_four" TEXT NOT NULL, + "runtime_environment_id" TEXT NOT NULL, + "created_by_user_id" TEXT, + "preset_id" TEXT, + "scopes" TEXT[] NOT NULL, + "last_used_at" TIMESTAMP(3), + "revoked_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "public"."api_keys"("key_hash"); + +-- CreateIndex +CREATE INDEX "api_keys_runtime_environment_id_revoked_at_created_at_idx" ON "public"."api_keys"("runtime_environment_id", "revoked_at", "created_at" DESC); + +-- AddForeignKey +ALTER TABLE "public"."api_keys" ADD CONSTRAINT "api_keys_runtime_environment_id_fkey" FOREIGN KEY ("runtime_environment_id") REFERENCES "public"."RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."api_keys" ADD CONSTRAINT "api_keys_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7d6f4ac549..2d220fbf25 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -69,6 +69,7 @@ model User { invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) invitationCodeId String? personalAccessTokens PersonalAccessToken[] + createdApiKeys ApiKey[] deployments WorkerDeployment[] backupCodes MfaBackupCode[] bulkActions BulkActionGroup[] @@ -392,6 +393,7 @@ model RuntimeEnvironment { playgroundConversations PlaygroundConversation[] errorGroupStates ErrorGroupState[] taskIdentifiers TaskIdentifier[] + apiKeys ApiKey[] revokedApiKeys RevokedApiKey[] // A partial unique index also enforces one STAGING/PREVIEW root per project and type. @@ -403,6 +405,31 @@ model RuntimeEnvironment { @@index([organizationId]) } +model ApiKey { + id String @id @default(cuid()) + name String + keyHash String @unique @map("key_hash") + lastFour String @map("last_four") + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String @map("runtime_environment_id") + + createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull, onUpdate: Cascade) + createdByUserId String? @map("created_by_user_id") + + presetId String? @map("preset_id") + scopes String[] + + lastUsedAt DateTime? @map("last_used_at") + revokedAt DateTime? @map("revoked_at") + expiresAt DateTime? @map("expires_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") + + @@index([runtimeEnvironmentId, revokedAt, createdAt(sort: Desc)]) + @@map("api_keys") +} + /// Records of previously-valid API keys that are still accepted for authentication /// during a grace window after rotation. Extend or end the grace period by updating `expiresAt`. model RevokedApiKey { diff --git a/internal-packages/rbac/src/ability.test.ts b/internal-packages/rbac/src/ability.test.ts index a3250bd003..3497ba6669 100644 --- a/internal-packages/rbac/src/ability.test.ts +++ b/internal-packages/rbac/src/ability.test.ts @@ -5,6 +5,7 @@ import { denyAbility, buildFallbackAbility, buildJwtAbility, + scopesWithinAbility, } from "./ability.js"; describe("permissiveAbility", () => { @@ -123,6 +124,43 @@ describe("buildJwtAbility", () => { }); }); +describe("scopesWithinAbility", () => { + it("allows subsets and preserves ids containing colons", () => { + const result = scopesWithinAbility( + ["read:runs:run_abc", "read:tags:env:staging"], + buildJwtAbility(["read:runs", "read:tags:env:staging"]) + ); + + expect(result).toEqual({ ok: true, deniedScopes: [] }); + }); + + it("rejects scopes that broaden or exceed the ability", () => { + const result = scopesWithinAbility( + ["trigger:tasks:send-email", "trigger:tasks", "read:runs"], + buildJwtAbility(["trigger:tasks:send-email"]) + ); + + expect(result).toEqual({ + ok: false, + deniedScopes: ["trigger:tasks", "read:runs"], + }); + }); + + it("allows arbitrary valid scopes for a permissive ability", () => { + expect(scopesWithinAbility(["read:runs", "admin"], permissiveAbility)).toEqual({ + ok: true, + deniedScopes: [], + }); + }); + + it("rejects malformed scopes for restricted abilities", () => { + expect(scopesWithinAbility(["read"], buildJwtAbility(["read:all"]))).toEqual({ + ok: false, + deniedScopes: ["read"], + }); + }); +}); + describe("buildJwtAbility — array resources", () => { it("authorizes when any resource in the array passes a scope check", () => { const ability = buildJwtAbility(["read:batch:batch_abc"]); diff --git a/internal-packages/rbac/src/ability.ts b/internal-packages/rbac/src/ability.ts index 6cf22d7643..9cd5cab68e 100644 --- a/internal-packages/rbac/src/ability.ts +++ b/internal-packages/rbac/src/ability.ts @@ -4,7 +4,7 @@ import type { RbacAbility } from "@trigger.dev/plugins"; // @trigger.dev/plugins so a public token decodes identically whoever // serves the request. Re-exported here so existing importers keep their // `./ability.js` import. -export { buildJwtAbility } from "@trigger.dev/plugins"; +export { buildJwtAbility, scopesWithinAbility } from "@trigger.dev/plugins"; /** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */ export const permissiveAbility: RbacAbility = { diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index 7ada941f75..4cf1e765c7 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -13,7 +13,11 @@ import type { RoleMutationResult, UserActorAuthResult, } from "@trigger.dev/plugins"; -import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/plugins"; +import { + FULL_ACCESS_PRESET_ID, + isUserActorToken, + verifyUserActorToken, +} from "@trigger.dev/plugins"; import { createHash } from "node:crypto"; import type { PrismaClient } from "@trigger.dev/database"; import { validateJWT } from "@trigger.dev/core/v3/jwt"; @@ -374,6 +378,34 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { return null; } + async apiKeyPresets(_organizationId: string) { + return null; + } + + async prepareApiKeyPolicy(params: { + organizationId: string; + presetId: string; + taskIdentifiers?: string[]; + }) { + // Without a plugin there is no preset catalogue, so full access is the only + // policy on offer, but the caller still has to ask for it by name. Any + // other preset, or any task selection, is a restricted key and unavailable. + if (params.presetId !== FULL_ACCESS_PRESET_ID || (params.taskIdentifiers?.length ?? 0) > 0) { + return { ok: false as const, error: "API key access presets are not available" }; + } + + // `presetId: null` because this install has no catalogue to reference. The + // persisted scopes remain the source of truth for authorization. + return { + ok: true as const, + policy: { presetId: null, scopes: ["admin"] }, + }; + } + + async describeApiKeyPolicy() { + return {}; + } + async allPermissions(): Promise { return []; } diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index eb34da4f0a..99140c1b3c 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -13,6 +13,8 @@ import type { PrismaClient } from "@trigger.dev/database"; import { RoleBaseAccessFallback } from "./fallback.js"; export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins"; export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins"; +export { buildJwtAbility, scopesWithinAbility } from "./ability.js"; +export { FULL_ACCESS_PRESET_ID, scopesGrantFullAccess } from "@trigger.dev/plugins"; // Re-export the user-actor token grammar so the webapp mints/checks tokens // through @trigger.dev/rbac (it doesn't import @trigger.dev/plugins directly). export { @@ -213,6 +215,20 @@ class LazyController implements RoleBaseAccessController { return (await this.c()).systemRoles(...args); } + async apiKeyPresets(...args: Parameters) { + return (await this.c()).apiKeyPresets(...args); + } + + async prepareApiKeyPolicy(...args: Parameters) { + return (await this.c()).prepareApiKeyPolicy(...args); + } + + async describeApiKeyPolicy( + ...args: Parameters + ) { + return (await this.c()).describeApiKeyPolicy(...args); + } + async allPermissions( ...args: Parameters ): Promise { diff --git a/packages/core/src/v3/jwt.ts b/packages/core/src/v3/jwt.ts index 6845225be8..b1dec457ad 100644 --- a/packages/core/src/v3/jwt.ts +++ b/packages/core/src/v3/jwt.ts @@ -14,6 +14,40 @@ export const JWT_ALGORITHM = "HS256"; export const JWT_ISSUER = "https://id.trigger.dev"; export const JWT_AUDIENCE = "https://api.trigger.dev"; +function decodeJWTPayload(token: string): unknown { + const parts = token.split("."); + const encodedPayload = parts[1]; + if (parts.length !== 3 || !encodedPayload) return; + + try { + const base64 = encodedPayload + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(encodedPayload.length / 4) * 4, "="); + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return; + } +} + +export function isPublicJWT(token: string): boolean { + const payload = decodeJWTPayload(token); + return ( + payload !== null && typeof payload === "object" && "pub" in payload && payload.pub === true + ); +} + +export function extractJWTSub(token: string): string | undefined { + const payload = decodeJWTPayload(token); + return payload !== null && + typeof payload === "object" && + "sub" in payload && + typeof payload.sub === "string" + ? payload.sub + : undefined; +} + export async function generateJWT(options: GenerateJWTOptions): Promise { const { SignJWT } = await import("jose"); diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 1c561d7452..198efce3fc 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -18,10 +18,22 @@ export type { RbacPluginConfig, RbacDatabaseConfig, SystemRole, + ApiKeyPreset, + ApiKeyPolicy, + PrepareApiKeyPolicyResult, + ApiKeyPolicyDescription, AuthenticatedEnvironment, + RbacScopeAction, + RbacScopeResourceType, } from "./rbac.js"; -export { buildJwtAbility } from "./rbac.js"; +export { + buildJwtAbility, + buildScope, + FULL_ACCESS_PRESET_ID, + scopesGrantFullAccess, + scopesWithinAbility, +} from "./rbac.js"; export { isUserActorToken, signUserActorToken, diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts index b31abd1215..7d5f910058 100644 --- a/packages/plugins/src/rbac.ts +++ b/packages/plugins/src/rbac.ts @@ -19,6 +19,27 @@ export type SystemRole = { available: boolean; }; +export type ApiKeyPreset = { + id: string; + label: string; + description: string; + usesTaskSelection: boolean; + available: boolean; +}; + +export type ApiKeyPolicy = { + presetId: string | null; + scopes: string[]; +}; + +export type PrepareApiKeyPolicyResult = + | { ok: true; policy: ApiKeyPolicy } + | { ok: false; error: string }; + +export type ApiKeyPolicyDescription = { + taskIdentifiers?: string[]; +}; + export type Permission = { // `:` — display name, derived from the ability rule. name: string; @@ -47,6 +68,18 @@ export type RbacSubject = | { type: "user"; userId: string; organizationId: string; projectId?: string } | { type: "personalAccessToken"; tokenId: string; organizationId: string; projectId?: string } | { type: "publicJWT"; environmentId: string; organizationId: string; projectId?: string } + // A host-owned additional environment API key. The host builds its ability + // from the effective scopes stored with the credential. Route builders use + // `restricted` to fail closed when a scoped key reaches an endpoint without a + // declared authorization resource. `apiKeyId` identifies the key for + // attribution. Root/legacy environment keys keep the `user` subject. + | { + type: "apiKey"; + apiKeyId: string; + restricted: boolean; + organizationId: string; + projectId?: string; + } // Delegated user-actor token (`tr_uat_…`): a short-lived, stateless // credential that authenticates as `userId`. `client` records what minted // it (e.g. a dashboard agent) for attribution. @@ -110,29 +143,91 @@ export interface RbacAbility { * which auth path serves the request — two copies of this grammar would * drift, and the difference would silently change what a token grants. */ +function parseScope(scope: string): { action: string; type?: string; id?: string } | undefined { + // Only the first two colons are delimiters — everything after the + // second colon is the resource id (which may itself contain colons, + // e.g. user-provided tags like "env:staging"). Naive + // `split(":")` + 3-tuple destructuring truncates such ids. + const parts = scope.split(":"); + const action = parts[0]; + if (!action) return undefined; + + return { + action, + type: parts[1] || undefined, + id: parts.length > 2 ? parts.slice(2).join(":") || undefined : undefined, + }; +} + +/** Only exact bare `admin` represents unrestricted access. */ +export function scopesGrantFullAccess(scopes: readonly string[]): boolean { + return scopes.includes("admin"); +} + +/** + * The one preset every install supports, including those with no preset + * catalogue at all. `prepareApiKeyPolicy` takes a required `presetId`, so + * callers that want a root-key-equivalent credential name this rather than + * relying on a default — see the note on that method. + */ +export const FULL_ACCESS_PRESET_ID = "FULL_ACCESS"; + +// The closed vocabulary a public token / API-key scope can address. This is +// the ONE place the `action:type[:id]` string grammar's terms are enumerated, +// shared by the scope *generator* (a plugin's preset builder) and the host's +// scope *checks* so the two cannot drift on a rename/typo. Every value here is +// already public: it appears in the OSS webapp's route authorization +// declarations and in `buildJwtAbility`'s grammar. A plugin that gates +// resources beyond this set can widen the union locally — do not add +// non-public terms here. +export type RbacScopeAction = "read" | "write" | "trigger" | "batchTrigger"; + +export type RbacScopeResourceType = + | "runs" + | "tasks" + | "batch" + | "queues" + | "deployments" + | "envvars" + | "apiKeys" + | "sessions" + | "waitpoints" + | "tags" + | "query"; + +/** + * Builds a single `action:type[:id]` scope string from typed parts. Scope + * generators should construct every scope through this helper so the shared + * `RbacScopeAction` / `RbacScopeResourceType` unions catch a mistyped or + * renamed term at compile time — the drift the grammar comment above warns + * about. `id` may itself contain colons (e.g. a tag like `env:staging`); it is + * appended verbatim and decoded by `parseScope`'s slice-join. + */ +export function buildScope( + action: RbacScopeAction, + type: RbacScopeResourceType, + id?: string +): string { + return id ? `${action}:${type}:${id}` : `${action}:${type}`; +} + export function buildJwtAbility(scopes: string[]): RbacAbility { const matches = (action: string, r: RbacResource): boolean => scopes.some((scope) => { - // Only the first two colons are delimiters — everything after the - // second colon is the resource id (which may itself contain colons, - // e.g. user-provided tags like "env:staging"). Naive - // `split(":")` + 3-tuple destructuring truncated such ids to the - // first segment and silently failed to match. - const parts = scope.split(":"); - const scopeAction = parts[0]; - const scopeType = parts[1]; - const scopeId = parts.length > 2 ? parts.slice(2).join(":") : undefined; + const parsed = parseScope(scope); + if (!parsed) return false; + // Bare `admin` is the universal wildcard. `admin:` is *not* — // it falls through to normal matching as action="admin" against // resources of that type. Treating `admin:` as universal // would silently broaden any such tokens beyond the narrow, // route-listed grant they had before scope-based abilities. - if (scopeAction === "admin" && !scopeType) return true; - if (scopeAction !== action && scopeAction !== "*") return false; - if (scopeType === "all") return true; - if (scopeType !== r.type) return false; - if (!scopeId) return true; - return scopeId === r.id; + if (parsed.action === "admin" && !parsed.type) return true; + if (parsed.action !== action && parsed.action !== "*") return false; + if (parsed.type === "all") return true; + if (parsed.type !== r.type) return false; + if (!parsed.id) return true; + return parsed.id === r.id; }); return { can(action: string, resource: RbacResource | RbacResource[]): boolean { @@ -148,6 +243,30 @@ export function buildJwtAbility(scopes: string[]): RbacAbility { }; } +/** + * Checks requested public-token scopes against an already-authenticated ability. + * Scope parsing intentionally lives beside buildJwtAbility so minting and + * validation use the same action:type[:id] grammar. + */ +export function scopesWithinAbility( + scopes: string[], + ability: RbacAbility +): { ok: boolean; deniedScopes: string[] } { + const deniedScopes = scopes.filter((scope) => { + const parsed = parseScope(scope); + if (!parsed || (!parsed.type && parsed.action !== "admin")) { + return true; + } + + return !ability.can(parsed.action, { + type: parsed.type ?? "all", + ...(parsed.id ? { id: parsed.id } : {}), + }); + }); + + return { ok: deniedScopes.length === 0, deniedScopes }; +} + // ── Delegated user-actor token grammar ─────────────────────────────────── // // A `tr_uat_…` token is the JWT body (signed HS256 with the platform secret) @@ -348,6 +467,21 @@ export interface RoleBaseAccessController { // an upgrade badge or hide them. systemRoles(organizationId: string): Promise; + // Plugin-owned API-key policy catalogue and policy generation. A null + // catalogue means no plugin is installed. The host owns credentials and + // persists the effective policy returned by prepareApiKeyPolicy(). + apiKeyPresets(organizationId: string): Promise; + // `presetId` is deliberately required: an authorization function must not + // have an implicit default, least of all a full-access one. A caller that + // omits the field should fail to compile rather than silently mint an admin + // credential. Installs with no preset catalogue pass "FULL_ACCESS". + prepareApiKeyPolicy(params: { + organizationId: string; + presetId: string; + taskIdentifiers?: string[]; + }): Promise; + describeApiKeyPolicy(policy: ApiKeyPolicy): Promise; + // Role introspection. The fallback returns []; a plugin may return // its own role catalogue. allPermissions(organizationId: string): Promise; From 0d0f794118453de1c42107b7fa3bba4114e967f3 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 13:06:13 +0100 Subject: [PATCH 03/16] refactor(rbac): make API key policy methods optional on the controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API-key policy methods are capability extensions, so declare them optional on RoleBaseAccessController and normalize the surface in LazyController rather than requiring every implementation to carry them. An authorization extension is compiled against whichever core commit its base image ships, so a required additive method forces both sides to move together and turns rolling an extension back into a build failure instead of a graceful degradation. Absence fails closed: no preset catalogue, and prepareApiKeyPolicy refuses outright rather than defaulting to full access, so an extension below this contract cannot mint a credential. Keys already issued are unaffected — they authorize from the scopes persisted on their row. loader.create() now returns HostRbacController, the total surface, so host callers neither guard nor invent their own absent-extension default. --- .../rbac/src/apiKeyPolicyDefaults.test.ts | 58 +++++++++++++++++++ internal-packages/rbac/src/index.ts | 58 ++++++++++++++++--- packages/plugins/src/rbac.ts | 26 ++++++++- 3 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 internal-packages/rbac/src/apiKeyPolicyDefaults.test.ts diff --git a/internal-packages/rbac/src/apiKeyPolicyDefaults.test.ts b/internal-packages/rbac/src/apiKeyPolicyDefaults.test.ts new file mode 100644 index 0000000000..c023bea1c3 --- /dev/null +++ b/internal-packages/rbac/src/apiKeyPolicyDefaults.test.ts @@ -0,0 +1,58 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect, it, vi } from "vitest"; + +// The API-key policy methods are OPTIONAL on RoleBaseAccessController so a +// plugin compiled against an older OSS commit still satisfies the contract +// (the plugin is built against whichever OSS source its base image carries). +// LazyController is what turns that partial surface into a total one, and these +// tests pin the defaults it substitutes — in particular that a missing +// prepareApiKeyPolicy fails CLOSED rather than resolving to full access. +// +// A stand-in for the cloud plugin, which isn't installed in this repo. The +// factory supplies the specifier, so no real module has to resolve. +vi.mock("@triggerdotdev/plugins/rbac", () => ({ + default: { + create: () => ({ + // Deliberately omits apiKeyPresets / prepareApiKeyPolicy / + // describeApiKeyPolicy — this is a pre-contract plugin. + isUsingPlugin: async () => true, + }), + }, +})); + +const prismaPlaceholder = {} as unknown as PrismaClient; + +describe("LazyController API-key policy defaults (plugin predates the contract)", () => { + async function controller() { + const loader = (await import("./index.js")).default; + const instance = loader.create(prismaPlaceholder); + // Guard against a silent fallback: if the mock didn't take, these + // assertions would be checking the fallback's real implementations. + await expect(instance.isUsingPlugin()).resolves.toBe(true); + return instance; + } + + it("reports no preset catalogue rather than throwing", async () => { + await expect((await controller()).apiKeyPresets("org_123")).resolves.toBeNull(); + }); + + it("refuses to prepare a policy — including FULL_ACCESS", async () => { + const result = await ( + await controller() + ).prepareApiKeyPolicy({ + organizationId: "org_123", + presetId: "FULL_ACCESS", + }); + + // The critical assertion: absence must never resolve to `{ ok: true }` with + // an admin scope. A plugin below the contract cannot mint any credential. + expect(result.ok).toBe(false); + expect(result).not.toHaveProperty("policy"); + }); + + it("describes a policy as having nothing extra to show", async () => { + await expect( + (await controller()).describeApiKeyPolicy({ presetId: "TRIGGER_ONLY", scopes: ["read:runs"] }) + ).resolves.toEqual({}); + }); +}); diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index 99140c1b3c..6873c9d479 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -1,5 +1,8 @@ import type { + ApiKeyPolicyDescription, + ApiKeyPreset, Permission, + PrepareApiKeyPolicyResult, RbacAbility, RbacDatabaseConfig, Role, @@ -12,6 +15,18 @@ import type { import type { PrismaClient } from "@trigger.dev/database"; import { RoleBaseAccessFallback } from "./fallback.js"; export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins"; + +/** + * The controller surface as the HOST sees it, after LazyController has filled in + * defaults for the optional capability methods a plugin may not implement. + * + * `RoleBaseAccessController` is the *plugin-facing* contract, where capability + * extensions are optional so an older plugin still satisfies it. Host code + * always talks to the LazyController singleton (`rbac`), which never omits a + * method — so host consumers should depend on this type, not on the plugin + * contract, and get a total surface without writing their own guards. + */ +export type HostRbacController = Required; export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins"; export { buildJwtAbility, scopesWithinAbility } from "./ability.js"; export { FULL_ACCESS_PRESET_ID, scopesGrantFullAccess } from "@trigger.dev/plugins"; @@ -215,18 +230,39 @@ class LazyController implements RoleBaseAccessController { return (await this.c()).systemRoles(...args); } - async apiKeyPresets(...args: Parameters) { - return (await this.c()).apiKeyPresets(...args); + // The API-key policy methods are optional on the controller contract (see the + // note on RoleBaseAccessController) so a plugin compiled against an older OSS + // commit still satisfies it. LazyController is where that optional surface is + // normalized into a total one: every host caller goes through `rbac`, so the + // absent-plugin default lives here once instead of at each call site. + async apiKeyPresets( + ...args: Parameters> + ): Promise { + const controller = await this.c(); + // Same meaning as the no-plugin fallback: no catalogue to offer. + return controller.apiKeyPresets ? controller.apiKeyPresets(...args) : null; } - async prepareApiKeyPolicy(...args: Parameters) { - return (await this.c()).prepareApiKeyPolicy(...args); + async prepareApiKeyPolicy( + ...args: Parameters> + ): Promise { + const controller = await this.c(); + if (!controller.prepareApiKeyPolicy) { + // Fail closed. A plugin that predates this contract must not be able to + // mint a credential — least of all a full-access one — so creation stops + // outright. Keys already issued are unaffected: they authorize from the + // scopes persisted on their row, compiled by the host bearer resolver. + return { ok: false, error: "API key access presets are not available" }; + } + return controller.prepareApiKeyPolicy(...args); } async describeApiKeyPolicy( - ...args: Parameters - ) { - return (await this.c()).describeApiKeyPolicy(...args); + ...args: Parameters> + ): Promise { + const controller = await this.c(); + // Presentation only — an undescribed policy renders from its stored scopes. + return controller.describeApiKeyPolicy ? controller.describeApiKeyPolicy(...args) : {}; } async allPermissions( @@ -309,7 +345,13 @@ class LazyController implements RoleBaseAccessController { class RoleBaseAccess { // Synchronous — returns a lazy controller that resolves any installed // plugin on first call. - create(prisma: RbacPrismaInput, options?: RbacCreateOptions): RoleBaseAccessController { + // + // Returns HostRbacController, not RoleBaseAccessController: the latter is the + // plugin-facing contract whose capability methods are optional, and + // LazyController has already substituted defaults for any the installed plugin + // omits. Handing back the total surface is what keeps host callers from having + // to guard (or, worse, from inventing their own absent-plugin default). + create(prisma: RbacPrismaInput, options?: RbacCreateOptions): HostRbacController { return new LazyController(prisma, options); } } diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts index 7d5f910058..52c6da8fdc 100644 --- a/packages/plugins/src/rbac.ts +++ b/packages/plugins/src/rbac.ts @@ -470,17 +470,37 @@ export interface RoleBaseAccessController { // Plugin-owned API-key policy catalogue and policy generation. A null // catalogue means no plugin is installed. The host owns credentials and // persists the effective policy returned by prepareApiKeyPolicy(). - apiKeyPresets(organizationId: string): Promise; + // + // These three are OPTIONAL, and are the first optional members of this + // interface — the distinction is deliberate. Methods the host cannot serve a + // request without (authenticateBearer, authenticatePat, authenticateSession) + // stay required. Capability extensions the host can degrade past are + // optional, so a plugin built against an older contract still satisfies this + // interface. That matters because the plugin is compiled against whichever + // OSS commit its base image carries: making an additive capability required + // turns every such addition into a lockstep two-repo merge, and turns a + // plugin rollback into an image build failure instead of a graceful + // degradation. + // + // Absence must fail CLOSED, and each default is chosen so it does: + // - apiKeyPresets -> null (identical to "no plugin installed") + // - prepareApiKeyPolicy -> { ok: false } — NEVER a full-access default; + // key creation stops, while already-issued keys + // keep authorizing from their persisted scopes + // - describeApiKeyPolicy -> {} (presentation only) + // LazyController applies these defaults, so host callers going through + // `rbac` see a total surface and cannot forget the guard. + apiKeyPresets?(organizationId: string): Promise; // `presetId` is deliberately required: an authorization function must not // have an implicit default, least of all a full-access one. A caller that // omits the field should fail to compile rather than silently mint an admin // credential. Installs with no preset catalogue pass "FULL_ACCESS". - prepareApiKeyPolicy(params: { + prepareApiKeyPolicy?(params: { organizationId: string; presetId: string; taskIdentifiers?: string[]; }): Promise; - describeApiKeyPolicy(policy: ApiKeyPolicy): Promise; + describeApiKeyPolicy?(policy: ApiKeyPolicy): Promise; // Role introspection. The fallback returns []; a plugin may return // its own role catalogue. From 51e11890198575312f99ba7b41582f8ffdb20425 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 16:22:16 +0100 Subject: [PATCH 04/16] fix(webapp): use _sk_ additional API key infix --- apps/webapp/app/utils/apiKeys.test.ts | 4 ++-- apps/webapp/app/utils/apiKeys.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/utils/apiKeys.test.ts b/apps/webapp/app/utils/apiKeys.test.ts index 288676634a..26e7d697da 100644 --- a/apps/webapp/app/utils/apiKeys.test.ts +++ b/apps/webapp/app/utils/apiKeys.test.ts @@ -20,7 +20,7 @@ describe("API key utilities", () => { expect(root.apiKey).toMatch(new RegExp(`^${prefix}[A-Za-z0-9]{24}$`)); expect(root.keyHash).toBe(hashApiKey(root.apiKey)); expect(root.lastFour).toBe(root.apiKey.slice(-4)); - expect(additional.apiKey).toMatch(new RegExp(`^${prefix}ak_[A-Za-z0-9]{24}$`)); + expect(additional.apiKey).toMatch(new RegExp(`^${prefix}sk_[A-Za-z0-9]{24}$`)); expect(additional.keyHash).toBe(hashApiKey(additional.apiKey)); expect(additional.lastFour).toBe(additional.apiKey.slice(-4)); expect(apiKeyPrefix(environmentType)).toBe(prefix); @@ -28,7 +28,7 @@ describe("API key utilities", () => { `${prefix}••••••••${root.lastFour}` ); expect(obfuscateApiKey(environmentType, additional.lastFour, "additional")).toBe( - `${prefix}ak_••••••••${additional.lastFour}` + `${prefix}sk_••••••••${additional.lastFour}` ); }); diff --git a/apps/webapp/app/utils/apiKeys.ts b/apps/webapp/app/utils/apiKeys.ts index 02464b4c78..556c203a49 100644 --- a/apps/webapp/app/utils/apiKeys.ts +++ b/apps/webapp/app/utils/apiKeys.ts @@ -25,7 +25,7 @@ export function generateRootApiKey(environmentType: RuntimeEnvironmentType) { } export function generateAdditionalApiKey(environmentType: RuntimeEnvironmentType) { - return generatedApiKey(`${apiKeyPrefix(environmentType)}ak_${apiKeyId()}`); + return generatedApiKey(`${apiKeyPrefix(environmentType)}sk_${apiKeyId()}`); } export function apiKeyPrefix(environmentType: RuntimeEnvironmentType): string { @@ -46,6 +46,6 @@ export function obfuscateApiKey( lastFour: string, kind: "root" | "additional" = "root" ): string { - const discriminator = kind === "additional" ? "ak_" : ""; + const discriminator = kind === "additional" ? "sk_" : ""; return `${apiKeyPrefix(environmentType)}${discriminator}••••••••${lastFour}`; } From 20ca0aaeecde1aa6e79d55e8f91ce33c127d8012 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 11:27:53 +0100 Subject: [PATCH 05/16] feat(webapp): enforce scopes for environment API keys --- .../app/models/runtimeEnvironment.server.ts | 147 +++++++-- .../v3/ApiRetrieveRunPresenter.server.ts | 39 ++- apps/webapp/app/routes/api.v1.artifacts.ts | 16 +- ...yments.$deploymentId.background-workers.ts | 13 +- ...api.v1.deployments.$deploymentId.cancel.ts | 15 +- .../api.v1.deployments.$deploymentId.fail.ts | 13 +- ...i.v1.deployments.$deploymentId.finalize.ts | 13 +- ...loymentId.generate-registry-credentials.ts | 15 +- ...i.v1.deployments.$deploymentId.progress.ts | 15 +- .../api.v1.deployments.$deploymentId.ts | 13 +- ....deployments.$deploymentVersion.promote.ts | 13 +- .../app/routes/api.v1.deployments.latest.ts | 13 +- apps/webapp/app/routes/api.v1.deployments.ts | 13 +- .../api.v1.projects.$projectRef.$env.ts | 37 ++- ...rojects.$projectRef.envvars.$slug.$name.ts | 38 ++- ...ojects.$projectRef.envvars.$slug.import.ts | 22 +- ...i.v1.projects.$projectRef.envvars.$slug.ts | 38 ++- .../api.v1.projects.$projectRef.envvars.ts | 15 +- ...queues.$queueParam.concurrency.override.ts | 4 + ...v1.queues.$queueParam.concurrency.reset.ts | 4 + .../routes/api.v1.queues.$queueParam.pause.ts | 4 + .../app/routes/api.v1.queues.$queueParam.ts | 4 + apps/webapp/app/routes/api.v1.queues.ts | 1 + .../routes/api.v1.runs.$runParam.replay.ts | 198 +++++------ apps/webapp/app/routes/api.v1.runs.ts | 29 +- apps/webapp/app/routes/api.v1.sessions.ts | 19 +- .../app/routes/api.v1.tasks.$taskId.batch.ts | 204 ++++++------ .../routes/api.v1.tasks.$taskId.trigger.ts | 23 +- apps/webapp/app/routes/api.v1.tasks.batch.ts | 50 +-- .../app/routes/api.v1.waitpoints.tokens.ts | 24 +- ...i.v2.deployments.$deploymentId.finalize.ts | 13 +- .../routes/api.v2.runs.$runParam.cancel.ts | 16 +- apps/webapp/app/routes/api.v2.tasks.batch.ts | 65 ++-- .../routes/api.v3.batches.$batchId.items.ts | 39 ++- apps/webapp/app/routes/api.v3.batches.ts | 69 ++-- ...i.v3.deployments.$deploymentId.finalize.ts | 13 +- apps/webapp/app/routes/api.v3.runs.$runId.ts | 4 +- apps/webapp/app/services/apiAuth.server.ts | 84 ++++- .../environmentVariableApiAccess.server.ts | 89 ++++- .../publicAccessTokenResponse.server.ts | 33 ++ .../app/services/realtime/jwtAuth.server.ts | 72 +--- .../routeBuilders/apiBuilder.server.ts | 91 ++++- .../app/utils/batchItemAuthorization.ts | 96 ++++++ .../utils/parentRunAuthorization.server.ts | 27 ++ .../app/utils/parentRunAuthorization.ts | 13 + .../app/utils/requestIdempotency.test.ts | 22 ++ .../webapp/app/utils/requestIdempotencyKey.ts | 12 + .../mollifier/resolveRunForMutation.server.ts | 37 ++- apps/webapp/test/apiAuthScope.test.ts | 107 ++++++ .../test/apiBuilderAuthorization.test.ts | 71 ++++ .../apiRetrieveRunPresenter.readroute.test.ts | 28 ++ .../test/environmentVariableApiAccess.test.ts | 111 +++++++ .../test/findEnvironmentByApiKey.test.ts | 142 ++++++++ .../mollifierResolveRunForMutation.test.ts | 20 +- .../projectEnvironmentCredentialRoute.test.ts | 104 ++++++ .../test/publicAccessTokenResponse.test.ts | 49 +++ apps/webapp/test/rbacFallbackBranch.test.ts | 310 ++++++++++++++++++ .../streamBatchItemsAuthorization.test.ts | 136 ++++++++ .../rbac/src/apiKeyPolicies.test.ts | 212 ++++++++++++ .../rbac/src/bearerCredentials.ts | 299 +++++++++++++++++ internal-packages/rbac/src/fallback.ts | 219 +------------ internal-packages/rbac/src/index.ts | 38 ++- 62 files changed, 2874 insertions(+), 819 deletions(-) create mode 100644 apps/webapp/app/services/publicAccessTokenResponse.server.ts create mode 100644 apps/webapp/app/utils/batchItemAuthorization.ts create mode 100644 apps/webapp/app/utils/parentRunAuthorization.server.ts create mode 100644 apps/webapp/app/utils/parentRunAuthorization.ts create mode 100644 apps/webapp/app/utils/requestIdempotency.test.ts create mode 100644 apps/webapp/app/utils/requestIdempotencyKey.ts create mode 100644 apps/webapp/test/apiAuthScope.test.ts create mode 100644 apps/webapp/test/apiBuilderAuthorization.test.ts create mode 100644 apps/webapp/test/environmentVariableApiAccess.test.ts create mode 100644 apps/webapp/test/projectEnvironmentCredentialRoute.test.ts create mode 100644 apps/webapp/test/publicAccessTokenResponse.test.ts create mode 100644 apps/webapp/test/streamBatchItemsAuthorization.test.ts create mode 100644 internal-packages/rbac/src/apiKeyPolicies.test.ts create mode 100644 internal-packages/rbac/src/bearerCredentials.ts diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 939cd55e94..9cfab3dbe5 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -5,7 +5,9 @@ import { runStore } from "~/v3/runStore.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; import { logger } from "~/services/logger.server"; import { getUsername } from "~/utils/username"; +import { hashApiKey } from "~/utils/apiKeys"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { scopesGrantFullAccess } from "@trigger.dev/rbac"; export type { RuntimeEnvironment }; @@ -93,11 +95,21 @@ export function toAuthenticated( }; } -export async function findEnvironmentByApiKey( +export type ApiKeyEnvironmentResolution = + | { ok: true; environment: AuthenticatedEnvironment } + | { ok: false; reason: "not-found" | "restricted" }; + +/** + * Resolve an environment from a raw API key for legacy routes that do not + * declare authorization. Additional keys are accepted only when their stored + * scopes explicitly grant full access; restricted keys fail closed here + * (`reason: "restricted"`, so callers can explain the rejection). + */ +async function resolveEnvironmentByApiKey( apiKey: string, branchName: string | undefined, - tx: PrismaClientOrTransaction = $replica -): Promise { + tx: PrismaClientOrTransaction +): Promise { const branch = sanitizeBranchName(branchName) ?? undefined; const include = { @@ -112,6 +124,8 @@ export async function findEnvironmentByApiKey( : undefined, } satisfies Prisma.RuntimeEnvironmentInclude; + const now = new Date(); + let additionalApiKey: { id: string; lastUsedAt: Date | null } | null = null; let environment = await tx.runtimeEnvironment.findFirst({ where: { apiKey, @@ -119,12 +133,12 @@ export async function findEnvironmentByApiKey( include, }); - // Fall back to keys that were revoked within the grace window + // Fall back to root keys that were rotated within the grace window. if (!environment) { const revokedApiKey = await tx.revokedApiKey.findFirst({ where: { apiKey, - expiresAt: { gt: new Date() }, + expiresAt: { gt: now }, }, include: { runtimeEnvironment: { include }, @@ -134,13 +148,64 @@ export async function findEnvironmentByApiKey( environment = revokedApiKey?.runtimeEnvironment ?? null; } + // Additional keys are host-owned credentials. Legacy routes cannot apply a + // scoped ability, so only an explicit full-access scope is accepted. if (!environment) { - return null; + const match = await tx.apiKey.findFirst({ + where: { + keyHash: hashApiKey(apiKey), + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + select: { + id: true, + lastUsedAt: true, + scopes: true, + runtimeEnvironment: { include }, + }, + }); + + if (match && !scopesGrantFullAccess(match.scopes)) { + return { ok: false, reason: "restricted" }; + } + + additionalApiKey = match ? { id: match.id, lastUsedAt: match.lastUsedAt } : null; + environment = match?.runtimeEnvironment ?? null; + } + + if (!environment) { + return { ok: false, reason: "not-found" }; + } + + if ( + additionalApiKey && + (!additionalApiKey.lastUsedAt || + additionalApiKey.lastUsedAt < new Date(now.getTime() - 300_000)) + ) { + try { + // Deliberately the primary `prisma`, not `tx`: `tx` defaults to the + // read replica (and may be a caller's transaction), and this last-used + // telemetry write must hit the writer. It's throttled to once every 5 + // minutes per key and best-effort — auth never fails if it can't record. + await prisma.apiKey.updateMany({ + where: { + id: additionalApiKey.id, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + data: { lastUsedAt: now }, + }); + } catch (error) { + logger.warn("Failed to update API key last-used timestamp", { + apiKeyId: additionalApiKey.id, + error, + }); + } } //don't return deleted projects if (environment.project.deletedAt !== null) { - return null; + return { ok: false, reason: "not-found" }; } if (environment.type === "PREVIEW") { @@ -148,23 +213,26 @@ export async function findEnvironmentByApiKey( logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", { environmentId: environment.id, }); - return null; + return { ok: false, reason: "not-found" }; } const childEnvironment = environment.childEnvironments.at(0); if (childEnvironment) { - return toAuthenticated({ - ...childEnvironment, - apiKey: environment.apiKey, - orgMember: environment.orgMember, - organization: environment.organization, - project: environment.project, - }); + return { + ok: true, + environment: toAuthenticated({ + ...childEnvironment, + apiKey: environment.apiKey, + orgMember: environment.orgMember, + organization: environment.organization, + project: environment.project, + }), + }; } //A branch was specified but no child environment was found - return null; + return { ok: false, reason: "not-found" }; } // If there is a named DEV branch (other than default), return it @@ -172,20 +240,49 @@ export async function findEnvironmentByApiKey( const childEnvironment = environment.childEnvironments.at(0); if (childEnvironment) { - return toAuthenticated({ - ...childEnvironment, - apiKey: environment.apiKey, - orgMember: environment.orgMember, - organization: environment.organization, - project: environment.project, - }); + return { + ok: true, + environment: toAuthenticated({ + ...childEnvironment, + apiKey: environment.apiKey, + orgMember: environment.orgMember, + organization: environment.organization, + project: environment.project, + }), + }; } //A branch was specified but no child environment was found - return null; + return { ok: false, reason: "not-found" }; } - return toAuthenticated(environment); + return { ok: true, environment: toAuthenticated(environment) }; +} + +/** + * Resolve an environment from a raw API key. Root and grace-window keys keep + * their legacy behavior; additional keys with restricted scopes fail closed. + */ +export async function findEnvironmentByApiKey( + apiKey: string, + branchName: string | undefined, + tx: PrismaClientOrTransaction = $replica +): Promise { + const resolution = await resolveEnvironmentByApiKey(apiKey, branchName, tx); + return resolution.ok ? resolution.environment : null; +} + +/** + * Like `findEnvironmentByApiKey`, but distinguishes a restricted additional + * key (fails closed on legacy routes) from an unknown key so callers can + * return an accurate error message. + */ +export async function findEnvironmentByApiKeyWithResolution( + apiKey: string, + branchName: string | undefined, + tx: PrismaClientOrTransaction = $replica +): Promise { + return resolveEnvironmentByApiKey(apiKey, branchName, tx); } /** diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index c6a14a4492..392c639b53 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -14,6 +14,7 @@ import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization"; import { BatchId } from "@trigger.dev/core/v3/isomorphic"; import { getUserProvidedIdempotencyKey } from "@trigger.dev/core/v3/serverOnly"; import type { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database"; +import type { RbacAbility } from "@trigger.dev/rbac"; import assertNever from "assert-never"; import type { API_VERSIONS, RunStatusUnspecifiedApiVersion } from "~/api/versions"; import { CURRENT_API_VERSION } from "~/api/versions"; @@ -83,6 +84,24 @@ type CommonRelatedRunWithVersion = CommonRelatedRun & { // ReturnType) so findRun can return a synthesised buffered // run without the type becoming self-referential. Exported so the // buffer-synthesis helper below can match this shape under unit test. +function canReadRelatedRun( + ability: RbacAbility | undefined, + run: CommonRelatedRunWithVersion +): boolean { + if (!ability) return true; + + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + + return ability.can("read", resources); +} + export type FoundRun = CommonRelatedRunWithVersion & { traceId: string; payload: string; @@ -212,7 +231,7 @@ export class ApiRetrieveRunPresenter { return synthesiseFoundRunFromBuffer(buffered); } - public async call(taskRun: FoundRun, env: AuthenticatedEnvironment) { + public async call(taskRun: FoundRun, env: AuthenticatedEnvironment, ability?: RbacAbility) { return startSpanWithEnv(tracer, "ApiRetrieveRunPresenter.call", env, async () => { let $payload: any; let $payloadPresignedUrl: string | undefined; @@ -290,14 +309,18 @@ export class ApiRetrieveRunPresenter { taskRun.engine === "V1" ? taskRun.attempts.length : (taskRun.attemptNumber ?? 0), attempts: [], relatedRuns: { - root: taskRun.rootTaskRun - ? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion) - : undefined, - parent: taskRun.parentTaskRun - ? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion) - : undefined, + root: + taskRun.rootTaskRun && canReadRelatedRun(ability, taskRun.rootTaskRun) + ? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion) + : undefined, + parent: + taskRun.parentTaskRun && canReadRelatedRun(ability, taskRun.parentTaskRun) + ? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion) + : undefined, children: await Promise.all( - taskRun.childRuns.map(async (r) => await createCommonRunStructure(r, this.apiVersion)) + taskRun.childRuns + .filter((run) => canReadRelatedRun(ability, run)) + .map(async (run) => await createCommonRunStructure(run, this.apiVersion)) ), }, }; diff --git a/apps/webapp/app/routes/api.v1.artifacts.ts b/apps/webapp/app/routes/api.v1.artifacts.ts index c74c66a222..a706f9e04e 100644 --- a/apps/webapp/app/routes/api.v1.artifacts.ts +++ b/apps/webapp/app/routes/api.v1.artifacts.ts @@ -4,7 +4,7 @@ import { CreateArtifactRequestBody, tryCatch, } from "@trigger.dev/core/v3"; -import { authenticateRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ArtifactsService } from "~/v3/services/artifacts.server"; @@ -14,17 +14,19 @@ export async function action({ request }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - apiKey: true, - organizationAccessToken: false, - personalAccessToken: false, + // Artifact uploads are part of the deploy flow (deployment context archive). + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, }); - if (!authenticationResult || !authenticationResult.result.ok) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = { result: authResult.authentication }; + const [, rawBody] = await tryCatch(request.json()); const body = CreateArtifactRequestBody.safeParse(rawBody ?? {}); diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts index 9e7052b727..f0d419ca18 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.background-workers.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { CreateDeclarativeScheduleError } from "~/v3/services/createBackgroundWorker.server"; @@ -26,13 +26,18 @@ export async function action({ request, params }: ActionFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts index d14f7a8bc2..168de47675 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts @@ -1,7 +1,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { CancelDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { DeploymentService } from "~/v3/services/deployment.server"; @@ -21,18 +21,17 @@ export async function action({ request, params }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - apiKey: true, - organizationAccessToken: false, - personalAccessToken: false, + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, }); - if (!authenticationResult || !authenticationResult.result.ok) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } - const { environment: authenticatedEnv } = authenticationResult.result; + const { environment: authenticatedEnv } = authResult.authentication; const { deploymentId } = parsedParams.data; const [, rawBody] = await tryCatch(request.json()); diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.fail.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.fail.ts index 7ba5dd3700..df596716a8 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.fail.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.fail.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { FailDeploymentRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { FailDeploymentService } from "~/v3/services/failDeployment.server"; @@ -24,13 +24,18 @@ export async function action({ request, params }: ActionFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts index f576f6eef3..7b9c541949 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.finalize.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { FinalizeDeploymentService } from "~/v3/services/finalizeDeployment.server"; @@ -25,13 +25,18 @@ export async function action({ request, params }: ActionFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts index 78488bfeeb..fe60b9ace4 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts @@ -5,7 +5,7 @@ import { tryCatch, } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { DeploymentService } from "~/v3/services/deployment.server"; @@ -25,18 +25,17 @@ export async function action({ request, params }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - apiKey: true, - organizationAccessToken: false, - personalAccessToken: false, + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, }); - if (!authenticationResult || !authenticationResult.result.ok) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } - const { environment: authenticatedEnv } = authenticationResult.result; + const { environment: authenticatedEnv } = authResult.authentication; const { deploymentId } = parsedParams.data; const [, rawBody] = await tryCatch(request.json()); diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts index 671f42606a..839e5a5cf5 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts @@ -1,7 +1,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { ProgressDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { DeploymentService } from "~/v3/services/deployment.server"; @@ -21,18 +21,17 @@ export async function action({ request, params }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - apiKey: true, - organizationAccessToken: false, - personalAccessToken: false, + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, }); - if (!authenticationResult || !authenticationResult.result.ok) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } - const { environment: authenticatedEnv } = authenticationResult.result; + const { environment: authenticatedEnv } = authResult.authentication; const { deploymentId } = parsedParams.data; const [, rawBody] = await tryCatch(request.json()); diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index dd16fabf24..6b7accd029 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -2,7 +2,7 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ @@ -18,13 +18,18 @@ export async function loader({ request, params }: LoaderFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts index f2c69078b9..c7cce0d1ed 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentVersion.promote.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server"; @@ -25,13 +25,18 @@ export async function action({ request, params }: ActionFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const url = new URL(request.url); diff --git a/apps/webapp/app/routes/api.v1.deployments.latest.ts b/apps/webapp/app/routes/api.v1.deployments.latest.ts index 7609f87425..9354db4de3 100644 --- a/apps/webapp/app/routes/api.v1.deployments.latest.ts +++ b/apps/webapp/app/routes/api.v1.deployments.latest.ts @@ -2,19 +2,24 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { WorkerInstanceGroupType } from "@trigger.dev/database"; import { prisma } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; export async function loader({ request }: LoaderFunctionArgs) { try { // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const deployment = await prisma.workerDeployment.findFirst({ diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 39988e1d13..273259d87d 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -5,7 +5,7 @@ import { type InitializeDeploymentResponseBody, } from "@trigger.dev/core/v3"; import { $replica } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -18,13 +18,18 @@ export async function action({ request, params }: ActionFunctionArgs) { } // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const rawBody = await request.json(); const body = InitializeDeploymentRequestBody.safeParse(rawBody); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts index 42ef412ec1..5c01c37560 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts @@ -4,11 +4,14 @@ import { z } from "zod"; import { env as processEnv } from "~/env.server"; import { authenticatedEnvironmentForAuthentication, - authenticateRequest, branchNameFromRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; +import { + authenticateEnvironmentScopedApiRequest, + authorizePatEnvironmentAccess, + presentedApiKeyFromAuthentication, +} from "~/services/environmentVariableApiAccess.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -27,15 +30,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { projectRef, env } = parsedParams.data; try { - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + // PAT/OAT authenticate on the legacy path; machine API keys go through + // the RBAC controller so additional keys (and their grants) are enforced. + const authResult = await authenticateEnvironmentScopedApiRequest(request, "read", "apiKeys"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -44,12 +45,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { branchNameFromRequest(request) ); - // This endpoint hands the caller the environment's secret key. For a PAT - // (a user), gate it on env-tier read:apiKeys — so a restricted role can't - // pull deployed credentials (and therefore can't deploy) via the CLI. + // User tokens bootstrap the environment's secret key, so gate them on + // env-tier read:apiKeys. Machine credentials are checked against the same + // permission before their presented key is returned below. const denied = await authorizePatEnvironmentAccess({ request, authType: authenticationResult.type, + ability: + authenticationResult.type === "apiKey" && authenticationResult.result.ok + ? authenticationResult.result.ability + : undefined, organizationId: environment.organizationId, projectId: environment.project.id, envType: environment.type, @@ -58,8 +63,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }); if (denied) return denied; + // API-key callers already possess a valid environment credential. Reuse + // exactly what they presented instead of exchanging it for the root key. + const presentedApiKey = presentedApiKeyFromAuthentication(authenticationResult); + const result: GetProjectEnvResponse = { - apiKey: environment.apiKey, + apiKey: presentedApiKey ?? environment.apiKey, name: environment.project.name, apiUrl: processEnv.API_ORIGIN ?? processEnv.APP_ORIGIN, projectId: environment.project.id, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts index 4e0fca9a48..49ce15ae18 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts @@ -4,12 +4,14 @@ import { UpdateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; import { - authenticateRequest, authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; +import { + authenticateEnvVarApiRequest, + authorizeEnvVarApiRequest, +} from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -26,15 +28,11 @@ export async function action({ params, request }: ActionFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "write"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -46,6 +44,10 @@ export async function action({ params, request }: ActionFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ request, authType: authenticationResult.type, + ability: + authenticationResult.type === "apiKey" && authenticationResult.result.ok + ? authenticationResult.result.ability + : undefined, organizationId: environment.organizationId, projectId: environment.project.id, envType: environment.type, @@ -126,15 +128,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } try { - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "read"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -146,6 +144,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ request, authType: authenticationResult.type, + ability: + authenticationResult.type === "apiKey" && authenticationResult.result.ok + ? authenticationResult.result.ability + : undefined, organizationId: environment.organizationId, projectId: environment.project.id, envType: environment.type, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts index 42b225f4e1..0f14d5b0f4 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts @@ -4,11 +4,13 @@ import { ImportEnvironmentVariablesRequestBody } from "@trigger.dev/core/v3"; import { parse } from "dotenv"; import { z } from "zod"; import { - authenticateRequest, authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; -import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; +import { + authenticateEnvVarApiRequest, + authorizeEnvVarApiRequest, +} from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -23,15 +25,11 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "write"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -43,6 +41,10 @@ export async function action({ params, request }: ActionFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ request, authType: authenticationResult.type, + ability: + authenticationResult.type === "apiKey" && authenticationResult.result.ok + ? authenticationResult.result.ability + : undefined, organizationId: environment.organizationId, projectId: environment.project.id, envType: environment.type, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index 49990204c1..97b4795c25 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -3,11 +3,13 @@ import { json } from "@remix-run/server-runtime"; import { CreateEnvironmentVariableRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { - authenticateRequest, authenticatedEnvironmentForAuthentication, branchNameFromRequest, } from "~/services/apiAuth.server"; -import { authorizeEnvVarApiRequest } from "~/services/environmentVariableApiAccess.server"; +import { + authenticateEnvVarApiRequest, + authorizeEnvVarApiRequest, +} from "~/services/environmentVariableApiAccess.server"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -22,15 +24,11 @@ export async function action({ params, request }: ActionFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "write"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -42,6 +40,10 @@ export async function action({ params, request }: ActionFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ request, authType: authenticationResult.type, + ability: + authenticationResult.type === "apiKey" && authenticationResult.result.ok + ? authenticationResult.result.ability + : undefined, organizationId: environment.organizationId, projectId: environment.project.id, envType: environment.type, @@ -85,15 +87,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - const authenticationResult = await authenticateRequest(request, { - personalAccessToken: true, - organizationAccessToken: true, - apiKey: true, - }); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + const authResult = await authenticateEnvVarApiRequest(request, "read"); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const environment = await authenticatedEnvironmentForAuthentication( authenticationResult, @@ -105,6 +103,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const denied = await authorizeEnvVarApiRequest({ request, authType: authenticationResult.type, + ability: + authenticationResult.type === "apiKey" && authenticationResult.result.ok + ? authenticationResult.result.ability + : undefined, organizationId: environment.organizationId, projectId: environment.project.id, envType: environment.type, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts index 151c182e16..07945df9ba 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts @@ -1,7 +1,7 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { resolveVariablesForEnvironment } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ParamsSchema = z.object({ @@ -15,11 +15,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: "Invalid params" }, { status: 400 }); } - // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + // Reading env vars requires the read:envvars scope (root keys authorize + // everything). + const authResult = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "envvars" }, + }); + if (!authResult.ok) { + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; const { projectRef } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts index 3296e7fa78..aa31e6aba5 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts @@ -16,6 +16,10 @@ const route = createActionApiRoute( params: z.object({ queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), }), + authorization: { + action: "write", + resource: (params) => ({ type: "queues", id: params.queueParam }), + }, }, async ({ params, body, authentication }) => { const input: RetrieveQueueParam = diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts index 9a83f3ed84..633324e86d 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.reset.ts @@ -15,6 +15,10 @@ const route = createActionApiRoute( params: z.object({ queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), }), + authorization: { + action: "write", + resource: (params) => ({ type: "queues", id: params.queueParam }), + }, }, async ({ params, body, authentication }) => { const input: RetrieveQueueParam = diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts index a0f910fe58..6c3c1f25dc 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts @@ -15,6 +15,10 @@ const route = createActionApiRoute( params: z.object({ queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), }), + authorization: { + action: "write", + resource: (params) => ({ type: "queues", id: params.queueParam }), + }, }, async ({ params, body, authentication }) => { const input: RetrieveQueueParam = diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts index a9bcd2342e..2f72805f62 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts @@ -15,6 +15,10 @@ export const loader = createLoaderApiRoute( }), searchParams: SearchParamsSchema, findResource: async () => 1, // This is a dummy function, we don't need to find a resource + authorization: { + action: "read", + resource: (_, params) => ({ type: "queues", id: params.queueParam }), + }, }, async ({ params, searchParams, authentication }) => { const input: RetrieveQueueParam = diff --git a/apps/webapp/app/routes/api.v1.queues.ts b/apps/webapp/app/routes/api.v1.queues.ts index b257a248a0..27fe2eb93d 100644 --- a/apps/webapp/app/routes/api.v1.queues.ts +++ b/apps/webapp/app/routes/api.v1.queues.ts @@ -24,6 +24,7 @@ export const loader = createLoaderApiRoute( { searchParams: SearchParamsSchema, findResource: async () => 1, // This is a dummy function, we don't need to find a resource + authorization: { action: "read", resource: () => ({ type: "queues" }) }, }, async ({ searchParams, authentication }) => { const service = new QueueListPresenter(searchParams.perPage); diff --git a/apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts b/apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts index df684946b1..f0bf949e35 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts @@ -1,13 +1,13 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { TaskRun } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { runStore } from "~/v3/runStore.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server"; import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server"; +import { resolveRunForMutation } from "~/v3/mollifier/resolveRunForMutation.server"; import { sanitizeTriggerSource } from "~/utils/triggerSource"; import { clientSafeErrorMessage } from "~/utils/prismaErrors"; @@ -49,108 +49,116 @@ const BufferedReplayInputSchema = z.object({ seedMetadataType: z.string().nullable().optional(), }); -export async function action({ request, params }: ActionFunctionArgs) { - // Ensure this is a POST request - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } - - // Authenticate the request - const authenticationResult = await authenticateApiRequest(request); - if (!authenticationResult) { - return json({ error: "Invalid or Missing API Key" }, { status: 401 }); - } - - const parsed = ParamsSchema.safeParse(params); - if (!parsed.success) { - return json({ error: "Invalid or missing run ID" }, { status: 400 }); - } - - const { runParam } = parsed.data; +const { action } = createActionApiRoute( + { + params: ParamsSchema, + method: "POST", + findResource: (params, authentication) => + resolveRunForMutation({ + runParam: params.runParam, + environmentId: authentication.environment.id, + organizationId: authentication.environment.organizationId, + }), + authorization: { + action: "write", + resource: (params, _, __, ___, run) => + run + ? anyResource([ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ]) + : { type: "runs", id: params.runParam }, + }, + }, + async ({ request, params, authentication }) => { + const { runParam } = params; - try { - const env = authenticationResult.environment; - // PG-first. Replay works on any status per audit — no - // filter beyond friendlyId is the existing semantic; findFirst with - // env scoping tightens it minimally without changing behaviour for - // a correctly-authed caller. - let taskRun: TaskRun | null = await runStore.findRun( - { - friendlyId: runParam, - runtimeEnvironmentId: env.id, - }, - prisma - ); + try { + const env = authentication.environment; + // PG-first. Replay works on any status per audit — no + // filter beyond friendlyId is the existing semantic; findFirst with + // env scoping tightens it minimally without changing behaviour for + // a correctly-authed caller. + let taskRun: TaskRun | null = await runStore.findRun( + { + friendlyId: runParam, + runtimeEnvironmentId: env.id, + }, + prisma + ); - if (!taskRun) { - // Buffered fallback. SyntheticRun carries every field - // ReplayTaskRunService reads from a TaskRun. Validate the subset of - // fields the service consumes (BufferedReplayInputSchema above) - // before casting; a schema mismatch surfaces as a 404 here rather - // than as a silent undefined deep inside the service. - const buffered = await findRunByIdWithMollifierFallback({ - runId: runParam, - environmentId: env.id, - organizationId: env.organizationId, - }); - if (buffered) { - const parsed = BufferedReplayInputSchema.safeParse(buffered); - if (parsed.success) { - // Manual sync point: `BufferedReplayInputSchema` covers only - // the subset of `TaskRun` fields `ReplayTaskRunService.call` - // currently reads from `existingTaskRun`. The cast is `as - // unknown as TaskRun` because the full `TaskRun` type carries - // ~40 fields the service never touches; mirroring all of them - // on a synthetic snapshot would be misleading. If a future - // change to `ReplayTaskRunService` reads an additional - // `existingTaskRun` field, **add it to the schema above** — - // otherwise the buffered path will silently feed the service - // `undefined` for that field while the PG-source replay - // works. The `safeParse` + warn-log + 404 below is the - // run-time fail-safe; this comment is the design fail-safe. - taskRun = parsed.data as unknown as TaskRun; - } else { - logger.warn("replay: buffered fallback failed schema validation", { - runParam, - issues: parsed.error.issues.map((issue) => ({ - path: issue.path.join("."), - code: issue.code, - })), - }); + if (!taskRun) { + // Buffered fallback. SyntheticRun carries every field + // ReplayTaskRunService reads from a TaskRun. Validate the subset of + // fields the service consumes (BufferedReplayInputSchema above) + // before casting; a schema mismatch surfaces as a 404 here rather + // than as a silent undefined deep inside the service. + const buffered = await findRunByIdWithMollifierFallback({ + runId: runParam, + environmentId: env.id, + organizationId: env.organizationId, + }); + if (buffered) { + const parsed = BufferedReplayInputSchema.safeParse(buffered); + if (parsed.success) { + // Manual sync point: `BufferedReplayInputSchema` covers only + // the subset of `TaskRun` fields `ReplayTaskRunService.call` + // currently reads from `existingTaskRun`. The cast is `as + // unknown as TaskRun` because the full `TaskRun` type carries + // ~40 fields the service never touches; mirroring all of them + // on a synthetic snapshot would be misleading. If a future + // change to `ReplayTaskRunService` reads an additional + // `existingTaskRun` field, **add it to the schema above** — + // otherwise the buffered path will silently feed the service + // `undefined` for that field while the PG-source replay + // works. The `safeParse` + warn-log + 404 below is the + // run-time fail-safe; this comment is the design fail-safe. + taskRun = parsed.data as unknown as TaskRun; + } else { + logger.warn("replay: buffered fallback failed schema validation", { + runParam, + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + })), + }); + } } } - } - if (!taskRun) { - return json({ error: "Run not found" }, { status: 404 }); - } + if (!taskRun) { + return json({ error: "Run not found" }, { status: 404 }); + } - const triggerSource = sanitizeTriggerSource(request.headers.get("x-trigger-source")) ?? "api"; + const triggerSource = sanitizeTriggerSource(request.headers.get("x-trigger-source")) ?? "api"; - const service = new ReplayTaskRunService(); - const newRun = await service.call(taskRun, { triggerSource }); + const service = new ReplayTaskRunService(); + const newRun = await service.call(taskRun, { triggerSource }); - if (!newRun) { - return json({ error: "Failed to create new run" }, { status: 400 }); - } + if (!newRun) { + return json({ error: "Failed to create new run" }, { status: 400 }); + } - return json({ - id: newRun?.friendlyId, - }); - } catch (error) { - if (error instanceof Error) { - logger.error("Failed to replay run", { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - run: runParam, + return json({ + id: newRun?.friendlyId, }); - return json({ error: clientSafeErrorMessage(error) }, { status: 400 }); - } else { - logger.error("Failed to replay run", { error: JSON.stringify(error), run: runParam }); - return json({ error: JSON.stringify(error) }, { status: 400 }); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to replay run", { + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + run: runParam, + }); + return json({ error: clientSafeErrorMessage(error) }, { status: 400 }); + } else { + logger.error("Failed to replay run", { error: JSON.stringify(error), run: runParam }); + return json({ error: JSON.stringify(error) }, { status: 400 }); + } } } -} +); + +export { action }; diff --git a/apps/webapp/app/routes/api.v1.runs.ts b/apps/webapp/app/routes/api.v1.runs.ts index a4f543e68f..dca246a0c2 100644 --- a/apps/webapp/app/routes/api.v1.runs.ts +++ b/apps/webapp/app/routes/api.v1.runs.ts @@ -3,7 +3,11 @@ import { ApiRunListPresenter, ApiRunListSearchParams, } from "~/presenters/v3/ApiRunListPresenter.server"; -import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, + everyResource, +} from "~/services/routeBuilders/apiBuilder.server"; export const loader = createLoaderApiRoute( { @@ -18,17 +22,18 @@ export const loader = createLoaderApiRoute( // and the legacy `checkAuthorization` iterated `Object.keys` — so a // JWT with type-level `read:tasks` (no id) granted access to the // unfiltered runs list. The new ability model only matches against - // resources we list, so the type-level `{ type: "tasks" }` element - // (alongside `{ type: "runs" }` and the per-id task elements) - // preserves that semantic — `read:tasks` JWTs in the wild still - // list unfiltered runs without needing a separate `read:runs` - // scope. Per-id `read:tasks:foo` still grants only when the - // filter includes `foo`. - return anyResource([ - { type: "runs" }, - { type: "tasks" }, - ...taskFilter.map((id) => ({ type: "tasks", id })), - ]); + // resources we list. Keep type-level runs/tasks as alternatives so + // broad scopes retain that behavior. ID-scoped keys, however, must + // match every task in a multi-task filter; matching one item must not + // expose the others. + if (taskFilter.length === 0) { + return anyResource([{ type: "runs" }, { type: "tasks" }]); + } + + return everyResource( + taskFilter.map((id) => ({ type: "tasks", id })), + [{ type: "runs" }, { type: "tasks" }] + ); }, }, findResource: async () => 1, // This is a dummy function, we don't need to find a resource diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index 1a10e6c6f0..c18412c481 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -27,6 +27,7 @@ import { anyResource, createActionApiRoute, createLoaderApiRoute, + everyResource, } from "~/services/routeBuilders/apiBuilder.server"; import { ServiceValidationError } from "~/v3/services/common.server"; import { runStore } from "~/v3/runStore.server"; @@ -48,15 +49,19 @@ export const loader = createLoaderApiRoute( // - Type-level `read:sessions` (the old superScope) matches the // sessions element (collection-level — no id) // - `read:all` / `admin` bypass via the JWT ability's wildcard branches - // The taskIdentifier filter accepts a string or an array; expand to - // one resource per task id so any per-task-scoped JWT among them - // grants access (the array gets OR semantics). + // The taskIdentifier filter accepts a string or an array. Broad + // sessions/tasks scopes remain alternatives, while ID-scoped keys must + // match every requested task so one allowed filter cannot expose others. resource: (_, __, searchParams) => { const taskFilter = asArray(searchParams["filter[taskIdentifier]"]) ?? []; - return anyResource([ - ...taskFilter.map((id) => ({ type: "tasks" as const, id })), - { type: "sessions" as const }, - ]); + if (taskFilter.length === 0) { + return anyResource([{ type: "sessions" as const }, { type: "tasks" as const }]); + } + + return everyResource( + taskFilter.map((id) => ({ type: "tasks" as const, id })), + [{ type: "sessions" as const }, { type: "tasks" as const }] + ); }, }, findResource: async () => 1, diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts index e57092bc66..2bd7bc5650 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts @@ -1,138 +1,134 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { BatchTriggerTaskV2RequestBody } from "@trigger.dev/core/v3"; import { BatchTriggerTaskRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { fromZodError } from "zod-validation-error"; import { MAX_BATCH_TRIGGER_ITEMS } from "~/consts"; +import { canWriteParentRun } from "~/utils/parentRunAuthorization.server"; import { clientSafeErrorMessage } from "~/utils/prismaErrors"; import { env } from "~/env.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { BatchTriggerV3Service } from "~/v3/services/batchTriggerV3.server"; import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger"; import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server"; +import { publicAccessTokenResponseHeaders } from "~/services/publicAccessTokenResponse.server"; const ParamsSchema = z.object({ taskId: z.string(), }); -export async function action({ request, params }: ActionFunctionArgs) { - // Ensure this is a POST request - if (request.method.toUpperCase() !== "POST") { - return { status: 405, body: "Method Not Allowed" }; - } - - // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const rawHeaders = Object.fromEntries(request.headers); - - const headers = HeadersSchema.safeParse(rawHeaders); - - if (!headers.success) { - return json({ error: "Invalid headers" }, { status: 400 }); - } - - const { - "idempotency-key": idempotencyKey, - "trigger-version": triggerVersion, - "x-trigger-span-parent-as-link": spanParentAsLink, - "x-trigger-worker": isFromWorker, - "x-trigger-realtime-streams-version": realtimeStreamsVersion, - traceparent, - tracestate, - } = headers.data; - - const { taskId } = ParamsSchema.parse(params); - - const contentLength = request.headers.get("content-length"); - - if (!contentLength || parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) { - return json({ error: "Request body too large" }, { status: 413 }); - } +const { action } = createActionApiRoute( + { + params: ParamsSchema, + headers: HeadersSchema, + body: BatchTriggerTaskRequestBody, + method: "POST", + maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE, + authorization: { + action: "batchTrigger", + resource: (params) => ({ type: "tasks", id: params.taskId }), + }, + }, + async ({ params, headers, body, authentication, ability }) => { + const { taskId } = params; + const { + "idempotency-key": idempotencyKey, + "trigger-version": triggerVersion, + "x-trigger-span-parent-as-link": spanParentAsLink, + "x-trigger-worker": isFromWorker, + "x-trigger-realtime-streams-version": realtimeStreamsVersion, + traceparent, + tracestate, + } = headers; + + logger.debug("Triggering batch", { + taskId, + idempotencyKey, + triggerVersion, + body, + }); - // Now parse the request body - const anyBody = await request.json(); + if (!body.items.length) { + return json({ error: "No items to trigger" }, { status: 400 }); + } - const body = BatchTriggerTaskRequestBody.safeParse(anyBody); + // Check the there are fewer than 100 items. This has to stay above the + // parent-run authorization below, which costs one lookup per distinct + // parent — an oversized batch must be rejected before it can spend them. + if (body.items.length > MAX_BATCH_TRIGGER_ITEMS) { + return json( + { + error: `Too many items. Maximum allowed batch size is ${MAX_BATCH_TRIGGER_ITEMS}.`, + }, + { status: 400 } + ); + } - if (!body.success) { - return json( - { error: fromZodError(body.error, { prefix: "Invalid batchTrigger call" }).toString() }, - { status: 400 } + const parentRunIds = Array.from( + new Set(body.items.map((item) => item.options?.parentRunId).filter((id) => id !== undefined)) ); - } - - logger.debug("Triggering batch", { - taskId, - idempotencyKey, - triggerVersion, - body: body.data, - }); - - if (!body.data.items.length) { - return json({ error: "No items to trigger" }, { status: 400 }); - } - - // Check the there are fewer than 100 items - if (body.data.items.length > MAX_BATCH_TRIGGER_ITEMS) { - return json( - { - error: `Too many items. Maximum allowed batch size is ${MAX_BATCH_TRIGGER_ITEMS}.`, - }, - { status: 400 } + const canWriteParentRuns = await Promise.all( + parentRunIds.map((parentRunId) => + canWriteParentRun( + ability, + authentication.environment.id, + authentication.environment.organizationId, + parentRunId + ) + ) ); - } + if (canWriteParentRuns.some((allowed) => !allowed)) { + return json({ error: "Unauthorized" }, { status: 403 }); + } - const service = new BatchTriggerV3Service(); + const service = new BatchTriggerV3Service(); - const traceContext = - traceparent && isFromWorker // If the request is from a worker, we should pass the trace context - ? { traceparent, tracestate } - : undefined; + const traceContext = + traceparent && isFromWorker // If the request is from a worker, we should pass the trace context + ? { traceparent, tracestate } + : undefined; - const v3Body = convertV1BodyToV2Body(body.data, taskId); + const v3Body = convertV1BodyToV2Body(body, taskId); - try { - const result = await service.call(authenticationResult.environment, v3Body, { - idempotencyKey: idempotencyKey ?? undefined, - triggerVersion: triggerVersion ?? undefined, - traceContext, - spanParentAsLink: spanParentAsLink === 1, - realtimeStreamsVersion: determineRealtimeStreamsVersion(realtimeStreamsVersion ?? undefined), - }); + try { + const result = await service.call(authentication.environment, v3Body, { + idempotencyKey: idempotencyKey ?? undefined, + triggerVersion: triggerVersion ?? undefined, + traceContext, + spanParentAsLink: spanParentAsLink === 1, + realtimeStreamsVersion: determineRealtimeStreamsVersion( + realtimeStreamsVersion ?? undefined + ), + }); - if (!result) { - return json({ error: "Task not found" }, { status: 404 }); - } + if (!result) { + return json({ error: "Task not found" }, { status: 404 }); + } - return json( - { - batchId: result.id, - runs: result.runs.map((run) => run.id), - }, - { - headers: { - "x-trigger-jwt-claims": JSON.stringify({ - sub: authenticationResult.environment.id, - pub: true, - }), + const $responseHeaders = await publicAccessTokenResponseHeaders({ + environment: authentication.environment, + scopes: [`read:batch:${result.id}`], + expirationTime: "1h", + }); + + return json( + { + batchId: result.id, + runs: result.runs.map((run) => run.id), }, + { headers: $responseHeaders } + ); + } catch (error) { + if (error instanceof Error) { + return json({ error: clientSafeErrorMessage(error) }, { status: 400 }); } - ); - } catch (error) { - if (error instanceof Error) { - return json({ error: clientSafeErrorMessage(error) }, { status: 400 }); - } - return json({ error: "Something went wrong" }, { status: 500 }); + return json({ error: "Something went wrong" }, { status: 500 }); + } } -} +); + +export { action }; // Strip from options: // - dependentBatch diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index 9d1e4c5b8d..6165049330 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -20,6 +20,8 @@ import { handleRequestIdempotency, saveRequestIdempotency, } from "~/utils/requestIdempotency.server"; +import { scopeRequestIdempotencyKey } from "~/utils/requestIdempotencyKey"; +import { canWriteParentRun } from "~/utils/parentRunAuthorization.server"; import { sanitizeTriggerSource } from "~/utils/triggerSource"; import { runStore } from "~/v3/runStore.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -60,7 +62,7 @@ const { action, loader } = createActionApiRoute( }, corsStrategy: "all", }, - async ({ body, headers, params, authentication }) => { + async ({ body, headers, params, authentication, ability }) => { const { "idempotency-key": idempotencyKey, "idempotency-key-ttl": idempotencyKeyTTL, @@ -76,7 +78,22 @@ const { action, loader } = createActionApiRoute( "x-trigger-source": triggerSourceHeader, } = headers; - const cachedResponse = await handleRequestIdempotency(requestIdempotencyKey, { + if ( + !(await canWriteParentRun( + ability, + authentication.environment.id, + authentication.environment.organizationId, + body.options?.parentRunId + )) + ) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + + const scopedIdempotencyKey = scopeRequestIdempotencyKey(requestIdempotencyKey, [ + authentication.environment.id, + params.taskId, + ]); + const cachedResponse = await handleRequestIdempotency(scopedIdempotencyKey, { requestType: "trigger", findCachedEntity: async (cachedRequestId) => { return await runStore.findRun( @@ -153,7 +170,7 @@ const { action, loader } = createActionApiRoute( // materialisation: once the run lands in PG, normal request- // idempotency from that point forward works as usual. if (!result.isMollified) { - await saveRequestIdempotency(requestIdempotencyKey, "trigger", result.run.id); + await saveRequestIdempotency(scopedIdempotencyKey, "trigger", result.run.id); } const $responseHeaders = await responseHeaders(result.run, authentication); diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index a43e0d3af5..5c9202d6fe 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -1,8 +1,6 @@ import { json } from "@remix-run/server-runtime"; -import type { BatchTriggerTaskV2Response } from "@trigger.dev/core/v3"; -import { BatchTriggerTaskV2RequestBody, generateJWT } from "@trigger.dev/core/v3"; +import { BatchTriggerTaskV2RequestBody } from "@trigger.dev/core/v3"; import { env } from "~/env.server"; -import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { getOneTimeUseToken } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { createActionApiRoute, everyResource } from "~/services/routeBuilders/apiBuilder.server"; @@ -16,7 +14,7 @@ import { OutOfEntitlementError } from "~/v3/services/triggerTask.server"; import { sanitizeTriggerSource } from "~/utils/triggerSource"; import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger"; import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server"; -import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server"; +import { publicAccessTokenResponseHeaders } from "~/services/publicAccessTokenResponse.server"; const { action, loader } = createActionApiRoute( { @@ -124,11 +122,11 @@ const { action, loader } = createActionApiRoute( triggerAction: "trigger", }); - const $responseHeaders = await responseHeaders( - batch, - authentication.environment, - triggerClient - ); + const $responseHeaders = await publicAccessTokenResponseHeaders({ + environment: authentication.environment, + scopes: [`read:batch:${batch.id}`], + expirationTime: "1h", + }); return json(batch, { status: 202, headers: $responseHeaders }); } catch (error) { @@ -163,38 +161,4 @@ const { action, loader } = createActionApiRoute( } ); -async function responseHeaders( - batch: BatchTriggerTaskV2Response, - environment: AuthenticatedEnvironment, - triggerClient?: string | null -): Promise> { - const claimsHeader = JSON.stringify({ - sub: environment.id, - pub: true, - }); - - if (triggerClient === "browser") { - const claims = { - sub: environment.id, - pub: true, - scopes: [`read:batch:${batch.id}`], - }; - - const jwt = await generateJWT({ - secretKey: extractJwtSigningSecretKey(environment), - payload: claims, - expirationTime: "1h", - }); - - return { - "x-trigger-jwt-claims": claimsHeader, - "x-trigger-jwt": jwt, - }; - } - - return { - "x-trigger-jwt-claims": claimsHeader, - }; -} - export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts index ace6128ac7..62322c527c 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts @@ -15,10 +15,10 @@ import { runOpsSplitReadEnabled, type PrismaClientOrTransaction, } from "~/db.server"; -import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { resolveRunIdMintKind } from "~/v3/engineVersion.server"; import { logger } from "~/services/logger.server"; import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; +import { publicAccessTokenResponseHeaders } from "~/services/publicAccessTokenResponse.server"; import { createActionApiRoute, createLoaderApiRoute, @@ -103,11 +103,16 @@ const { action } = createActionApiRoute( standaloneResidency: residency, }); - const $responseHeaders = await responseHeaders(authentication.environment); + const waitpointId = WaitpointId.toFriendlyId(result.waitpoint.id); + const $responseHeaders = await publicAccessTokenResponseHeaders({ + environment: authentication.environment, + scopes: [`write:waitpoints:${waitpointId}`], + expirationTime: "24h", + }); return json( { - id: WaitpointId.toFriendlyId(result.waitpoint.id), + id: waitpointId, isCached: result.isCached, url: generateHttpCallbackUrl(result.waitpoint.id, authentication.environment.apiKey), }, @@ -124,17 +129,4 @@ const { action } = createActionApiRoute( } ); -async function responseHeaders( - environment: AuthenticatedEnvironment -): Promise> { - const claimsHeader = JSON.stringify({ - sub: environment.id, - pub: true, - }); - - return { - "x-trigger-jwt-claims": claimsHeader, - }; -} - export { action }; diff --git a/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts b/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts index 0fece044fb..b7e6bdc2f2 100644 --- a/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts +++ b/apps/webapp/app/routes/api.v2.deployments.$deploymentId.finalize.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { FinalizeDeploymentV2Service } from "~/v3/services/finalizeDeploymentV2.server"; @@ -24,13 +24,18 @@ export async function action({ request, params }: ActionFunctionArgs) { } // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts index cb660049c9..18a5d2cc30 100644 --- a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts +++ b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts @@ -1,7 +1,7 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { env as appEnv } from "~/env.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server"; import { mutateWithFallback } from "~/v3/mollifier/mutateWithFallback.server"; @@ -19,10 +19,6 @@ const { action } = createActionApiRoute( params: ParamsSchema, allowJWT: true, corsStrategy: "none", - authorization: { - action: "write", - resource: (params) => ({ type: "runs", id: params.runParam }), - }, // PG-or-buffer resolver. Returning null here would 404 BEFORE the // action runs (`apiBuilder.server.ts:321`), so buffered cancels need // a buffer check at this layer too. Logic lives in a helper so the @@ -36,6 +32,16 @@ const { action } = createActionApiRoute( environmentId: auth.environment.id, organizationId: auth.environment.organizationId, }), + authorization: { + action: "write", + resource: (params, _, __, ___, run) => + run + ? anyResource([ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ]) + : { type: "runs", id: params.runParam }, + }, }, async ({ params, authentication }) => { const runId = params.runParam; diff --git a/apps/webapp/app/routes/api.v2.tasks.batch.ts b/apps/webapp/app/routes/api.v2.tasks.batch.ts index c766bb474f..5dcbf13e0f 100644 --- a/apps/webapp/app/routes/api.v2.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v2.tasks.batch.ts @@ -12,6 +12,8 @@ import { handleRequestIdempotency, saveRequestIdempotency, } from "~/utils/requestIdempotency.server"; +import { scopeRequestIdempotencyKey } from "~/utils/requestIdempotencyKey"; +import { canWriteParentRun } from "~/utils/parentRunAuthorization.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { BatchProcessingStrategy } from "~/v3/services/batchTriggerV3.server"; import { OutOfEntitlementError } from "~/v3/services/triggerTask.server"; @@ -45,11 +47,22 @@ const { action, loader } = createActionApiRoute( }, corsStrategy: "all", }, - async ({ body, headers, params, authentication }) => { + async ({ body, headers, params, authentication, ability }) => { if (!body.items.length) { return json({ error: "Batch cannot be triggered with no items" }, { status: 400 }); } + if ( + !(await canWriteParentRun( + ability, + authentication.environment.id, + authentication.environment.organizationId, + body.parentRunId + )) + ) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + // Check the there are fewer than MAX_BATCH_V2_TRIGGER_ITEMS items if (body.items.length > env.MAX_BATCH_V2_TRIGGER_ITEMS) { return json( @@ -87,7 +100,11 @@ const { action, loader } = createActionApiRoute( requestIdempotencyKey, }); - const cachedResponse = await handleRequestIdempotency(requestIdempotencyKey, { + const scopedIdempotencyKey = scopeRequestIdempotencyKey(requestIdempotencyKey, [ + authentication.environment.id, + ...Array.from(new Set(body.items.map((item) => item.task))).sort(), + ]); + const cachedResponse = await handleRequestIdempotency(scopedIdempotencyKey, { requestType: "batch-trigger", findCachedEntity: async (cachedRequestId) => { const batch = await runStore.findBatchTaskRunById(cachedRequestId); @@ -99,7 +116,7 @@ const { action, loader } = createActionApiRoute( runCount: cachedBatch.runCount, }), buildResponseHeaders: async (responseBody, cachedEntity) => { - return await responseHeaders(responseBody, authentication.environment, triggerClient); + return await responseHeaders(responseBody, authentication.environment); }, }); @@ -116,7 +133,7 @@ const { action, loader } = createActionApiRoute( const service = new RunEngineBatchTriggerService(batchProcessingStrategy ?? undefined); service.onBatchTaskRunCreated.attachOnce(async (batch) => { - await saveRequestIdempotency(requestIdempotencyKey, "batch-trigger", batch.id); + await saveRequestIdempotency(scopedIdempotencyKey, "batch-trigger", batch.id); }); try { @@ -132,11 +149,7 @@ const { action, loader } = createActionApiRoute( triggerAction: "trigger", }); - const $responseHeaders = await responseHeaders( - batch, - authentication.environment, - triggerClient - ); + const $responseHeaders = await responseHeaders(batch, authentication.environment); return json(batch, { status: 202, @@ -176,35 +189,23 @@ const { action, loader } = createActionApiRoute( async function responseHeaders( batch: BatchTriggerTaskV3Response, - environment: AuthenticatedEnvironment, - triggerClient?: string | null + environment: AuthenticatedEnvironment ): Promise> { - const claimsHeader = JSON.stringify({ + const claims = { sub: environment.id, pub: true, - }); - - if (triggerClient === "browser") { - const claims = { - sub: environment.id, - pub: true, - scopes: [`read:batch:${batch.id}`], - }; - - const jwt = await generateJWT({ - secretKey: extractJwtSigningSecretKey(environment), - payload: claims, - expirationTime: "1h", - }); + scopes: [`read:batch:${batch.id}`], + }; - return { - "x-trigger-jwt-claims": claimsHeader, - "x-trigger-jwt": jwt, - }; - } + const jwt = await generateJWT({ + secretKey: extractJwtSigningSecretKey(environment), + payload: claims, + expirationTime: "1h", + }); return { - "x-trigger-jwt-claims": claimsHeader, + "x-trigger-jwt-claims": JSON.stringify({ sub: environment.id, pub: true }), + "x-trigger-jwt": jwt, }; } diff --git a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts index c34dbc178c..9e2bd51025 100644 --- a/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts +++ b/apps/webapp/app/routes/api.v3.batches.$batchId.items.ts @@ -6,8 +6,12 @@ import { createNdjsonParserStream, streamToAsyncIterable, } from "~/runEngine/services/streamBatchItems.server"; -import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { rbac } from "~/services/rbac.server"; +import { + authorizedBatchItemStream, + BatchItemAuthorizationError, +} from "~/utils/batchItemAuthorization"; import { ServiceValidationError } from "~/v3/services/baseService.server"; const ParamsSchema = z.object({ @@ -48,13 +52,22 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } - // Authenticate the request - const authResult = await authenticateApiRequestWithFailure(request, { - allowPublicKey: true, - }); + // This streaming route cannot use createActionApiRoute because the body must + // remain an unread stream. Use the same RBAC controller directly and apply + // its ability to every parsed item below. + // + // Because we bypass the route builder, we also bypass its + // `restrictedApiKey && !authorization -> 403` fail-closed. Authorization is + // instead enforced per item by `authorizedBatchItemStream` below, which also + // requires a restricted credential to present at least one authorized item + // before the service may touch the batch — otherwise an empty stream would + // reach it having passed no checks at all. Any logic added between here and + // that call runs authenticated but NOT authorized, so keep new per-request + // work behind it. + const authResult = await rbac.authenticateBearer(request, { allowJWT: true }); if (!authResult.ok) { - return json({ error: authResult.error }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } // Get the request body stream @@ -76,8 +89,14 @@ export async function action({ request, params }: ActionFunctionArgs) { // Pipe the request body through the parser const parsedStream = body.pipeThrough(parser); - // Convert to async iterable for the service - const itemsIterator = streamToAsyncIterable(parsedStream); + // Convert to async iterable for the service. This authorizes the first item + // eagerly, so a stream that yields no items is rejected before the service + // can report anything about the batch. + const itemsIterator = await authorizedBatchItemStream( + streamToAsyncIterable(parsedStream), + authResult.ability, + batchId + ); // Process the stream const service = new StreamBatchItemsService(); @@ -88,6 +107,10 @@ export async function action({ request, params }: ActionFunctionArgs) { return json(result, { status: 200 }); } catch (error) { + if (error instanceof BatchItemAuthorizationError) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + // Customer-facing validation failures (invalid item shape, invalid JSON // in the streamed body). The handler returns 4xx with the message; // system handles it gracefully, no alert needed. diff --git a/apps/webapp/app/routes/api.v3.batches.ts b/apps/webapp/app/routes/api.v3.batches.ts index 3179e2fa6a..4e2c4b4b33 100644 --- a/apps/webapp/app/routes/api.v3.batches.ts +++ b/apps/webapp/app/routes/api.v3.batches.ts @@ -5,11 +5,14 @@ import { env } from "~/env.server"; import { BatchRateLimitExceededError } from "~/runEngine/concerns/batchLimits.server"; import { CreateBatchService } from "~/runEngine/services/createBatch.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import type { RbacAbility } from "@trigger.dev/rbac"; import { getOneTimeUseToken } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server"; import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { batchPublicAccessScopes } from "~/utils/batchItemAuthorization"; +import { canWriteParentRun } from "~/utils/parentRunAuthorization.server"; import { clientSafeErrorMessage } from "~/utils/prismaErrors"; import { handleRequestIdempotency, @@ -37,18 +40,29 @@ const { action, loader } = createActionApiRoute( maxContentLength: 131_072, // 128KB is plenty for the batch metadata authorization: { action: "batchTrigger", - // No specific tasks to authorize at batch creation time — tasks are - // validated when items are streamed. Collection-level check. - resource: () => ({ type: "tasks" }), + // No specific tasks exist yet. This grant only creates the batch shell; + // phase two authorizes every streamed task before enqueueing it. + resource: () => anyResource([{ type: "batch" }, { type: "tasks" }]), }, corsStrategy: "all", }, - async ({ body, headers, authentication }) => { + async ({ body, headers, authentication, ability }) => { // Validate runCount if (body.runCount <= 0) { return json({ error: "runCount must be a positive integer" }, { status: 400 }); } + if ( + !(await canWriteParentRun( + ability, + authentication.environment.id, + authentication.environment.organizationId, + body.parentRunId + )) + ) { + return json({ error: "Unauthorized" }, { status: 403 }); + } + // Check runCount against limit if (body.runCount > env.STREAMING_BATCH_MAX_ITEMS) { return json( @@ -99,7 +113,12 @@ const { action, loader } = createActionApiRoute( isCached: true, }), buildResponseHeaders: async (responseBody) => { - return await responseHeaders(responseBody, authentication.environment, triggerClient); + return await responseHeaders( + responseBody, + authentication.environment, + ability, + triggerClient + ); }, }); @@ -132,6 +151,7 @@ const { action, loader } = createActionApiRoute( const $responseHeaders = await responseHeaders( batch, authentication.environment, + ability, triggerClient ); @@ -198,34 +218,27 @@ const { action, loader } = createActionApiRoute( async function responseHeaders( batch: CreateBatchResponse, environment: AuthenticatedEnvironment, + ability: RbacAbility, triggerClient?: string | null ): Promise> { - const claimsHeader = JSON.stringify({ - sub: environment.id, - pub: true, - }); - - if (triggerClient === "browser") { - const claims = { + // Browser clients need a delegated token for phase two because they must not + // retain a private API key. Selected-task credentials only receive read access + // and must continue to authorize each streamed item with their private key. + const scopes = batchPublicAccessScopes(batch.id, ability, triggerClient === "browser"); + + const jwt = await generateJWT({ + secretKey: extractJwtSigningSecretKey(environment), + payload: { sub: environment.id, pub: true, - scopes: [`read:batch:${batch.id}`, `write:batch:${batch.id}`], - }; - - const jwt = await generateJWT({ - secretKey: extractJwtSigningSecretKey(environment), - payload: claims, - expirationTime: "1h", - }); - - return { - "x-trigger-jwt-claims": claimsHeader, - "x-trigger-jwt": jwt, - }; - } + scopes, + }, + expirationTime: "1h", + }); return { - "x-trigger-jwt-claims": claimsHeader, + "x-trigger-jwt-claims": JSON.stringify({ sub: environment.id, pub: true }), + "x-trigger-jwt": jwt, }; } diff --git a/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts b/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts index 87fff8bbf7..2fdcc0de21 100644 --- a/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts +++ b/apps/webapp/app/routes/api.v3.deployments.$deploymentId.finalize.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { FinalizeDeploymentV2Service } from "~/v3/services/finalizeDeploymentV2.server"; @@ -24,13 +24,18 @@ export async function action({ request, params }: ActionFunctionArgs) { } // Next authenticate the request - const authenticationResult = await authenticateApiRequest(request); + const authResult = await authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }); - if (!authenticationResult) { + if (!authResult.ok) { logger.info("Invalid or missing api key", { url: request.url }); - return json({ error: "Invalid or Missing API key" }, { status: 401 }); + return json({ error: authResult.error }, { status: authResult.status }); } + const authenticationResult = authResult.authentication; + const authenticatedEnv = authenticationResult.environment; const { deploymentId } = parsedParams.data; diff --git a/apps/webapp/app/routes/api.v3.runs.$runId.ts b/apps/webapp/app/routes/api.v3.runs.$runId.ts index 5debce7035..0e7cecbbe0 100644 --- a/apps/webapp/app/routes/api.v3.runs.$runId.ts +++ b/apps/webapp/app/routes/api.v3.runs.$runId.ts @@ -31,9 +31,9 @@ export const loader = createLoaderApiRoute( }, }, }, - async ({ authentication, resource, apiVersion }) => { + async ({ authentication, ability, resource, apiVersion }) => { const presenter = new ApiRetrieveRunPresenter(apiVersion); - const result = await presenter.call(resource, authentication.environment); + const result = await presenter.call(resource, authentication.environment, ability); if (!result) { return json( diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 899b693a98..5a8c1e786a 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -9,9 +9,11 @@ import { authIncludeBase, authIncludeWithParent, findEnvironmentByApiKey, + findEnvironmentByApiKeyWithResolution, findEnvironmentByPublicApiKey, toAuthenticated, } from "~/models/runtimeEnvironment.server"; +import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { logger } from "./logger.server"; import { safeEnvironmentLogFields } from "./safeEnvironmentLog"; @@ -28,6 +30,7 @@ import { } from "./organizationAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { rbac } from "./rbac.server"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), @@ -58,6 +61,10 @@ export type ApiAuthenticationResultSuccess = { realtime?: { skipColumns?: string[]; }; + // Present when authentication went through the RBAC bearer controller. + // Legacy direct authentication intentionally omits it and remains fail-closed + // for restricted additional keys. + ability?: RbacAbility; // Present when the request used a public JWT minted from a PAT/UAT exchange // that stamped an `act` delegation claim. `actor.sub` is the acting user id, // used for attribution (e.g. who resolved an error). Absent for plain env @@ -117,7 +124,11 @@ export async function authenticateApiRequestWithFailure( */ export async function authenticateApiKey( apiKey: string, - options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {} + options: { + allowPublicKey?: boolean; + allowJWT?: boolean; + branchName?: string; + } = {} ): Promise { const result = getApiKeyResult(apiKey); @@ -184,7 +195,11 @@ export async function authenticateApiKey( */ async function authenticateApiKeyWithFailure( apiKey: string, - options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {} + options: { + allowPublicKey?: boolean; + allowJWT?: boolean; + branchName?: string; + } = {} ): Promise { const result = getApiKeyResult(apiKey); @@ -226,18 +241,24 @@ async function authenticateApiKeyWithFailure( }; } case "PRIVATE": { - const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName); - if (!environment) { + const resolution = await findEnvironmentByApiKeyWithResolution( + result.apiKey, + options.branchName + ); + if (!resolution.ok) { return { ok: false, - error: "Invalid API Key", + error: + resolution.reason === "restricted" + ? "This endpoint does not support restricted API keys. Use an API key with full environment access." + : "Invalid API Key", }; } return { ok: true, ...result, - environment, + environment: resolution.environment, }; } case "PUBLIC_JWT": { @@ -260,6 +281,52 @@ async function authenticateApiKeyWithFailure( } } +/** + * Authenticate an API-key request for a legacy (non-apiBuilder) route that + * needs to accept granular additional keys, then enforce that the key's ability + * authorizes `action` on `resource`. Root keys (and grace-window root keys) + * carry the unrestricted `admin` ability, preserving pre-granular behavior. + * + * Only apiKey credentials are accepted (no PAT / org token / public key). Use + * this for routes previously guarded by a bare `authenticateApiRequest` call. + */ +export async function authenticateApiKeyWithScope( + request: Request, + { + action, + resource, + allowJWT = false, + }: { action: string; resource: RbacResource; allowJWT?: boolean } +): Promise< + | { ok: true; authentication: ApiAuthenticationResultSuccess } + | { ok: false; status: 401 | 403; error: string } +> { + const apiKey = getApiKeyFromHeader(request.headers.get("Authorization")); + if (!apiKey) { + return { ok: false, status: 401, error: "Invalid or Missing API key" }; + } + + const result = await rbac.authenticateAuthorizeBearer( + request, + { action, resource }, + { allowJWT } + ); + if (!result.ok) { + return result; + } + + return { + ok: true, + authentication: { + ok: true, + apiKey, + type: "PRIVATE", + environment: result.environment, + ability: result.ability, + }, + }; +} + export async function authenticateAuthorizationHeader( authorization: string, { @@ -434,7 +501,10 @@ export async function authenticateRequest< } if (allowedMethods.apiKey) { - const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName }); + const result = await authenticateApiKey(apiKey, { + allowPublicKey: false, + branchName, + }); if (!result) { return; diff --git a/apps/webapp/app/services/environmentVariableApiAccess.server.ts b/apps/webapp/app/services/environmentVariableApiAccess.server.ts index 11d401125a..e9fe2ef781 100644 --- a/apps/webapp/app/services/environmentVariableApiAccess.server.ts +++ b/apps/webapp/app/services/environmentVariableApiAccess.server.ts @@ -1,10 +1,73 @@ import { json } from "@remix-run/server-runtime"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { isUserActorToken } from "@trigger.dev/rbac"; +import type { RbacAbility } from "@trigger.dev/rbac"; +import { + authenticateApiKeyWithScope, + authenticateRequest, + type AuthenticationResult, +} from "~/services/apiAuth.server"; import { rbac } from "~/services/rbac.server"; type EnvironmentScopedResource = "envvars" | "apiKeys"; +type EnvironmentScopedAuthentication = + | { ok: true; authentication: AuthenticationResult } + | { ok: false; status: 401 | 403; error: string }; + +/** + * Returns the credential already presented by an authenticated API-key caller. + * API-key exchanges must reuse this value instead of exposing the environment's + * root credential. + */ +export function presentedApiKeyFromAuthentication( + authentication: AuthenticationResult +): string | undefined { + return authentication.type === "apiKey" && authentication.result.ok + ? authentication.result.apiKey + : undefined; +} + +/** + * Keep PAT/OAT authentication on the legacy path while routing machine API + * keys through the RBAC controller, where plugin grants are applied. + */ +export async function authenticateEnvironmentScopedApiRequest( + request: Request, + action: "read" | "write", + resource: EnvironmentScopedResource +): Promise { + const userOrOrganizationAuthentication = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); + if (userOrOrganizationAuthentication) { + return { ok: true, authentication: userOrOrganizationAuthentication }; + } + + const apiKeyAuthentication = await authenticateApiKeyWithScope(request, { + action, + resource: { type: resource }, + }); + if (!apiKeyAuthentication.ok) { + return apiKeyAuthentication; + } + + return { + ok: true, + authentication: { type: "apiKey", result: apiKeyAuthentication.authentication }, + }; +} + +/** Env var API routes: PAT/OAT on the legacy path, machine keys via RBAC. */ +export function authenticateEnvVarApiRequest( + request: Request, + action: "read" | "write" +): Promise { + return authenticateEnvironmentScopedApiRequest(request, action, "envvars"); +} + const RESOURCE_LABELS: Record = { envvars: "environment variables", apiKeys: "API keys", @@ -14,8 +77,8 @@ const RESOURCE_LABELS: Record = { * Env-tier RBAC for environment-scoped API routes (env vars, and the endpoints * that hand out an environment's secret credentials). * - * Machine credentials (an environment's secret/public API key) are already - * scoped to a single environment, so they pass through unchanged. A personal + * Machine credentials (an environment's API key) are authorized by the + * ability returned by the RBAC bearer controller. A personal * access token (or a delegated user-actor token) carries a user, so enforce * that user's role for the targeted environment tier — e.g. a Developer can't * read deployed env vars or API keys via the API, matching the dashboard @@ -34,6 +97,7 @@ export async function authorizePatEnvironmentAccess({ envType, resource, action, + ability, }: { request: Request; authType: "personalAccessToken" | "organizationAccessToken" | "apiKey"; @@ -42,6 +106,8 @@ export async function authorizePatEnvironmentAccess({ envType: RuntimeEnvironmentType; resource: EnvironmentScopedResource; action: "read" | "write"; + // Controller ability for API-key credentials. Absent for PAT/OAT callers. + ability?: RbacAbility; }): Promise { const bearer = request.headers .get("Authorization") @@ -49,8 +115,22 @@ export async function authorizePatEnvironmentAccess({ .trim(); const isUat = !!bearer && isUserActorToken(bearer); - // Machine creds (apiKey) and org tokens carry no user role to enforce. A - // user-actor token carries a user just like a PAT, so it's gated too. + // Machine API keys are authorized by their controller ability. Root keys and + // ungranted additional keys are permissive; granted keys are restricted. + if (authType === "apiKey") { + if (ability?.can(action, { type: resource })) { + return undefined; + } + return json( + { + error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`, + }, + { status: 403 } + ); + } + + // Org tokens carry no user role to enforce. A user-actor token carries a + // user just like a PAT, so it's gated too. if (authType !== "personalAccessToken" && !isUat) { return undefined; } @@ -82,6 +162,7 @@ export function authorizeEnvVarApiRequest(opts: { projectId: string; envType: RuntimeEnvironmentType; action: "read" | "write"; + ability?: RbacAbility; }): Promise { return authorizePatEnvironmentAccess({ ...opts, resource: "envvars" }); } diff --git a/apps/webapp/app/services/publicAccessTokenResponse.server.ts b/apps/webapp/app/services/publicAccessTokenResponse.server.ts new file mode 100644 index 0000000000..f4f20bcf7d --- /dev/null +++ b/apps/webapp/app/services/publicAccessTokenResponse.server.ts @@ -0,0 +1,33 @@ +import { generateJWT } from "@trigger.dev/core/v3"; +import { resolveJwtSigningKey } from "@trigger.dev/rbac"; + +export type PublicAccessTokenEnvironment = { + id: string; + apiKey: string; + parentEnvironment?: { apiKey: string } | null; +}; + +export async function publicAccessTokenResponseHeaders({ + environment, + scopes, + expirationTime, +}: { + environment: PublicAccessTokenEnvironment; + scopes: string[]; + expirationTime: string; +}): Promise> { + const jwt = await generateJWT({ + secretKey: resolveJwtSigningKey(environment), + payload: { + sub: environment.id, + pub: true, + scopes, + }, + expirationTime, + }); + + return { + "x-trigger-jwt-claims": JSON.stringify({ sub: environment.id, pub: true }), + "x-trigger-jwt": jwt, + }; +} diff --git a/apps/webapp/app/services/realtime/jwtAuth.server.ts b/apps/webapp/app/services/realtime/jwtAuth.server.ts index ed99121430..2806a73701 100644 --- a/apps/webapp/app/services/realtime/jwtAuth.server.ts +++ b/apps/webapp/app/services/realtime/jwtAuth.server.ts @@ -1,4 +1,10 @@ -import { validateJWT, type ValidationResult } from "@trigger.dev/core/v3/jwt"; +import { + extractJWTSub, + isPublicJWT, + validateJWT, + type ValidationResult, +} from "@trigger.dev/core/v3/jwt"; +import { resolveJwtSigningKey } from "@trigger.dev/rbac"; import { $replica } from "~/db.server"; import { findEnvironmentById } from "~/models/runtimeEnvironment.server"; import type { AuthenticatedEnvironment } from "../apiAuth.server"; @@ -33,10 +39,10 @@ export async function validatePublicJwtKey(token: string): Promise { const result = await rbac.authenticateBearer(request, { allowJWT }); if (!result.ok) { @@ -78,7 +83,47 @@ async function authenticateRequestForApiBuilder( actor: result.jwt?.act, }; - return { ok: true, authentication, ability: result.ability }; + return { + ok: true, + authentication, + ability: result.ability, + restrictedApiKey: result.subject.type === "apiKey" && result.subject.restricted, + }; +} + +export function shouldRejectRestrictedKeyWithoutAuthorization( + restrictedApiKey: boolean, + hasAuthorization: boolean +): boolean { + return restrictedApiKey && !hasAuthorization; +} + +async function rejectRestrictedKeyWithoutAuthorization({ + request, + restrictedApiKey, + hasAuthorization, + useCors, +}: { + request: Request; + restrictedApiKey: boolean; + hasAuthorization: boolean; + useCors: boolean; +}): Promise { + if (!shouldRejectRestrictedKeyWithoutAuthorization(restrictedApiKey, hasAuthorization)) return; + + return wrapResponse( + request, + json( + { + error: "Unauthorized", + code: "unauthorized", + param: "access_token", + type: "authorization", + }, + { status: 403 } + ), + useCors + ); } type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; @@ -115,14 +160,18 @@ type AnyResourceAuth = { type EveryResourceAuth = { readonly [EVERY_RESOURCE_MARKER]: true; readonly resources: readonly RbacResource[]; + readonly orResources: readonly RbacResource[]; }; export function anyResource(resources: RbacResource[]): AnyResourceAuth { return { [ANY_RESOURCE_MARKER]: true, resources }; } -export function everyResource(resources: RbacResource[]): EveryResourceAuth { - return { [EVERY_RESOURCE_MARKER]: true, resources }; +export function everyResource( + resources: RbacResource[], + orResources: RbacResource[] = [] +): EveryResourceAuth { + return { [EVERY_RESOURCE_MARKER]: true, resources, orResources }; } function isAnyResource(value: unknown): value is AnyResourceAuth { @@ -143,8 +192,15 @@ function isEveryResource(value: unknown): value is EveryResourceAuth { type AuthResource = RbacResource | AnyResourceAuth | EveryResourceAuth; -function checkAuth(ability: RbacAbility, action: string, resource: AuthResource): boolean { +export function checkAuth(ability: RbacAbility, action: string, resource: AuthResource): boolean { if (isEveryResource(resource)) { + // A broad collection grant may be supplied as an alternative to all + // instance checks. This preserves scopes such as read:runs while making + // mixed selected-task filters require access to every requested task. + if (resource.orResources.length > 0 && ability.can(action, [...resource.orResources])) { + return true; + } + // Empty array via [].every() is vacuously true — would let any token // pass auth. Routes building everyResource() from request bodies // (e.g. batch trigger items) should never produce zero elements @@ -223,6 +279,7 @@ type ApiKeyHandlerFunction< ? z.infer : undefined; authentication: ApiAuthenticationResultSuccess; + ability: RbacAbility; request: Request; resource: NonNullable; apiVersion: API_VERSIONS; @@ -263,6 +320,13 @@ export function createLoaderApiRoute< ); } const { authentication: authenticationResult, ability } = authResult; + const restrictedKeyRejection = await rejectRestrictedKeyWithoutAuthorization({ + request, + restrictedApiKey: authResult.restrictedApiKey, + hasAuthorization: authorization !== undefined, + useCors: corsStrategy !== "none", + }); + if (restrictedKeyRejection) return restrictedKeyRejection; let parsedParams: any = undefined; if (paramsSchema) { @@ -364,6 +428,7 @@ export function createLoaderApiRoute< searchParams: parsedSearchParams, headers: parsedHeaders, authentication: authenticationResult, + ability, request, resource, apiVersion, @@ -986,6 +1051,7 @@ type ApiKeyActionHandlerFunction< ? z.infer : undefined; authentication: ApiAuthenticationResultSuccess; + ability: RbacAbility; request: Request; resource?: TResource; }) => Promise; @@ -1055,6 +1121,13 @@ export function createActionApiRoute< ); } const { authentication: authenticationResult, ability } = authResult; + const restrictedKeyRejection = await rejectRestrictedKeyWithoutAuthorization({ + request, + restrictedApiKey: authResult.restrictedApiKey, + hasAuthorization: authorization !== undefined, + useCors: corsStrategy !== "none", + }); + if (restrictedKeyRejection) return restrictedKeyRejection; if (maxContentLength) { const contentLength = request.headers.get("content-length"); @@ -1212,6 +1285,7 @@ export function createActionApiRoute< headers: parsedHeaders, body: parsedBody, authentication: authenticationResult, + ability, request, resource, }) @@ -1340,6 +1414,13 @@ export function createMultiMethodApiRoute< ); } const { authentication: authenticationResult, ability } = authResult; + const restrictedKeyRejection = await rejectRestrictedKeyWithoutAuthorization({ + request, + restrictedApiKey: authResult.restrictedApiKey, + hasAuthorization: authorization !== undefined, + useCors: corsStrategy !== "none", + }); + if (restrictedKeyRejection) return restrictedKeyRejection; if (maxContentLength) { const contentLength = request.headers.get("content-length"); diff --git a/apps/webapp/app/utils/batchItemAuthorization.ts b/apps/webapp/app/utils/batchItemAuthorization.ts new file mode 100644 index 0000000000..45b1988877 --- /dev/null +++ b/apps/webapp/app/utils/batchItemAuthorization.ts @@ -0,0 +1,96 @@ +import type { RbacAbility } from "@trigger.dev/rbac"; + +export class BatchItemAuthorizationError extends Error {} + +export function batchPublicAccessScopes( + batchId: string, + ability: RbacAbility, + browserClient: boolean +): string[] { + const scopes = [`read:batch:${batchId}`]; + + if (browserClient && ability.can("batchTrigger", { type: "tasks" })) { + scopes.push(`write:batch:${batchId}`); + } + + return scopes; +} + +/** + * Wraps `authorizeBatchItems` so a credential without a batch-level write grant + * must present at least one authorized item before the batch is touched at all. + * + * A selected-task credential can only be authorized by the items it streams — + * by design, see the phase-two comment in `api.v3.batches.ts`. So a stream that + * yields nothing passes zero checks, and because `StreamBatchItemsService` does + * its batch lookup *before* it ever pulls an item, its "not found" / "not in + * PENDING (current: …)" / `runCount` responses would leak an arbitrary batch's + * existence, lifecycle state and progress to any key in the environment. This + * route bypasses the route builder's `restrictedApiKey && !authorization -> 403` + * fail-closed, so nothing else stops that probe. + * + * Pulling and authorizing the first item up front closes it: no item, no + * authorization. The auth layer must never grant on no input — the same rule + * `checkAuth` applies for empty resource arrays in `apiBuilder.server.ts`. + */ +export async function authorizedBatchItemStream( + items: AsyncIterable, + ability: RbacAbility, + batchId: string +): Promise> { + const authorized = authorizeBatchItems(items, ability, batchId); + + // A batch-level write grant is authorization in its own right, so an empty + // stream stays legal for credentials that own the batch: root keys (via the + // permissive ability) and phase one's delegated browser token. Only a + // restricted credential, which has nothing but its items to go on, is held + // to the at-least-one-item rule. + if (ability.can("write", { type: "batch", id: batchId })) { + return authorized; + } + + const iterator = authorized[Symbol.asyncIterator](); + const first = await iterator.next(); + + if (first.done) { + throw new BatchItemAuthorizationError(); + } + + return (async function* () { + try { + yield first.value; + + while (true) { + const next = await iterator.next(); + if (next.done) return; + yield next.value; + } + } finally { + // Consumers can stop early — p-map abandons the iterator when a mapper + // rejects. Close the generator we pulled from so it unwinds its own + // `for await` and releases the request body stream. + await iterator.return?.(); + } + })(); +} + +export async function* authorizeBatchItems( + items: AsyncIterable, + ability: RbacAbility, + batchId: string +): AsyncIterable { + const canWriteBatch = ability.can("write", { type: "batch", id: batchId }); + + for await (const item of items) { + const task = + typeof item === "object" && item !== null && "task" in item && typeof item.task === "string" + ? item.task + : undefined; + + if (!canWriteBatch && (!task || !ability.can("batchTrigger", { type: "tasks", id: task }))) { + throw new BatchItemAuthorizationError(); + } + + yield item; + } +} diff --git a/apps/webapp/app/utils/parentRunAuthorization.server.ts b/apps/webapp/app/utils/parentRunAuthorization.server.ts new file mode 100644 index 0000000000..fac9b96dd2 --- /dev/null +++ b/apps/webapp/app/utils/parentRunAuthorization.server.ts @@ -0,0 +1,27 @@ +import type { RbacAbility } from "@trigger.dev/rbac"; +import { resolveRunForMutation } from "~/v3/mollifier/resolveRunForMutation.server"; +import { canWriteResolvedParentRun } from "./parentRunAuthorization"; + +export async function canWriteParentRun( + ability: RbacAbility, + environmentId: string, + organizationId: string, + parentRunId: string | null | undefined +): Promise { + if (!parentRunId) return true; + + // A type-level `write:runs` grant covers every run in the environment, so + // resolving the parent could only confirm what we already know. Root keys and + // any other unrestricted credential take this branch, which keeps the trigger + // hot path free of the extra lookup. + if (ability.can("write", { type: "runs" })) return true; + + const run = await resolveRunForMutation({ + environmentId, + organizationId, + runParam: parentRunId, + }); + if (!run) return false; + + return canWriteResolvedParentRun(ability, run); +} diff --git a/apps/webapp/app/utils/parentRunAuthorization.ts b/apps/webapp/app/utils/parentRunAuthorization.ts new file mode 100644 index 0000000000..fc245ed242 --- /dev/null +++ b/apps/webapp/app/utils/parentRunAuthorization.ts @@ -0,0 +1,13 @@ +import type { RbacAbility } from "@trigger.dev/rbac"; + +type ParentRunResource = { + friendlyId: string; + taskIdentifier: string; +}; + +export function canWriteResolvedParentRun(ability: RbacAbility, run: ParentRunResource): boolean { + return ability.can("write", [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ]); +} diff --git a/apps/webapp/app/utils/requestIdempotency.test.ts b/apps/webapp/app/utils/requestIdempotency.test.ts new file mode 100644 index 0000000000..a422646c63 --- /dev/null +++ b/apps/webapp/app/utils/requestIdempotency.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { scopeRequestIdempotencyKey } from "./requestIdempotencyKey"; + +describe("scopeRequestIdempotencyKey", () => { + it("keeps retries stable within the same environment and task scope", () => { + expect(scopeRequestIdempotencyKey("request-1", ["env-1", "task-a"])).toBe( + scopeRequestIdempotencyKey("request-1", ["env-1", "task-a"]) + ); + }); + + it("does not share cache entries across environments or tasks", () => { + const original = scopeRequestIdempotencyKey("request-1", ["env-1", "task-a"]); + + expect(scopeRequestIdempotencyKey("request-1", ["env-1", "task-b"])).not.toBe(original); + expect(scopeRequestIdempotencyKey("request-1", ["env-2", "task-a"])).not.toBe(original); + }); + + it("preserves missing request keys", () => { + expect(scopeRequestIdempotencyKey(undefined, ["env-1", "task-a"])).toBeUndefined(); + expect(scopeRequestIdempotencyKey(null, ["env-1", "task-a"])).toBeNull(); + }); +}); diff --git a/apps/webapp/app/utils/requestIdempotencyKey.ts b/apps/webapp/app/utils/requestIdempotencyKey.ts new file mode 100644 index 0000000000..90594f1a0b --- /dev/null +++ b/apps/webapp/app/utils/requestIdempotencyKey.ts @@ -0,0 +1,12 @@ +import { createHash } from "node:crypto"; + +export function scopeRequestIdempotencyKey( + requestIdempotencyKey: string | null | undefined, + scope: readonly string[] +): string | null | undefined { + if (!requestIdempotencyKey) return requestIdempotencyKey; + + return createHash("sha256") + .update(JSON.stringify([requestIdempotencyKey, ...scope])) + .digest("hex"); +} diff --git a/apps/webapp/app/v3/mollifier/resolveRunForMutation.server.ts b/apps/webapp/app/v3/mollifier/resolveRunForMutation.server.ts index 258c0da211..4679ef91b7 100644 --- a/apps/webapp/app/v3/mollifier/resolveRunForMutation.server.ts +++ b/apps/webapp/app/v3/mollifier/resolveRunForMutation.server.ts @@ -15,8 +15,8 @@ import { getMollifierBuffer as defaultGetBuffer } from "./mollifierBuffer.server // `findResource: async () => null`, which made every cancel 404 before // the action ran. The helper makes the lookup unit-testable.) export type ResolvedRunForMutation = - | { source: "pg"; friendlyId: string } - | { source: "buffer"; friendlyId: string }; + | { source: "pg"; friendlyId: string; taskIdentifier: string } + | { source: "buffer"; friendlyId: string; taskIdentifier: string }; export type ResolveRunForMutationDeps = { prismaReplica?: PrismaReplicaClient; @@ -36,17 +36,34 @@ export async function resolveRunForMutation(input: { const pgRun = await runStore.findRun( { friendlyId: input.runParam, runtimeEnvironmentId: input.environmentId }, - { select: { friendlyId: true } }, + { select: { friendlyId: true, taskIdentifier: true } }, replica ); - if (pgRun) return { source: "pg", friendlyId: pgRun.friendlyId }; + if (pgRun) { + return { + source: "pg", + friendlyId: pgRun.friendlyId, + taskIdentifier: pgRun.taskIdentifier, + }; + } const buffer = getBuffer(); if (buffer) { const entry = await buffer.getEntry(input.runParam); if (entry && entry.envId === input.environmentId && entry.orgId === input.organizationId) { - return { source: "buffer", friendlyId: input.runParam }; + try { + const snapshot = JSON.parse(entry.payload) as { taskIdentifier?: unknown }; + if (typeof snapshot.taskIdentifier === "string") { + return { + source: "buffer", + friendlyId: input.runParam, + taskIdentifier: snapshot.taskIdentifier, + }; + } + } catch { + // A malformed snapshot is not an authorizable run resource. + } } } @@ -64,10 +81,16 @@ export async function resolveRunForMutation(input: { // downstream mutateWithFallback flow would otherwise handle correctly. const writerRun = await runStore.findRun( { friendlyId: input.runParam, runtimeEnvironmentId: input.environmentId }, - { select: { friendlyId: true } }, + { select: { friendlyId: true, taskIdentifier: true } }, writer ); - if (writerRun) return { source: "pg", friendlyId: writerRun.friendlyId }; + if (writerRun) { + return { + source: "pg", + friendlyId: writerRun.friendlyId, + taskIdentifier: writerRun.taskIdentifier, + }; + } return null; } diff --git a/apps/webapp/test/apiAuthScope.test.ts b/apps/webapp/test/apiAuthScope.test.ts new file mode 100644 index 0000000000..5ddf7ffe5a --- /dev/null +++ b/apps/webapp/test/apiAuthScope.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rbacMocks = vi.hoisted(() => ({ + authenticateAuthorizeBearer: vi.fn<(...args: any[]) => Promise>(), +})); + +vi.mock("~/services/rbac.server", () => ({ rbac: rbacMocks })); +vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); +vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } })); +vi.mock("~/models/project.server", () => ({ findProjectByRef: vi.fn() })); +vi.mock("~/models/runtimeEnvironment.server", () => ({ + authIncludeBase: {}, + authIncludeWithParent: {}, + findEnvironmentByApiKey: vi.fn(), + findEnvironmentByPublicApiKey: vi.fn(), + toAuthenticated: vi.fn(), +})); +vi.mock("~/services/personalAccessToken.server", () => ({ + authenticateApiRequestWithPersonalAccessToken: vi.fn(), + isPersonalAccessToken: () => false, +})); +vi.mock("~/services/organizationAccessToken.server", () => ({ + authenticateApiRequestWithOrganizationAccessToken: vi.fn(), + isOrganizationAccessToken: () => false, +})); +vi.mock("~/services/realtime/jwtAuth.server", () => ({ + isPublicJWT: () => false, + validatePublicJwtKey: vi.fn(), +})); +vi.mock("~/services/logger.server", () => ({ + logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; + +describe("authenticateApiKeyWithScope", () => { + beforeEach(() => { + rbacMocks.authenticateAuthorizeBearer.mockReset(); + }); + + it("returns 401 without a bearer credential", async () => { + const result = await authenticateApiKeyWithScope(new Request("https://example.com"), { + action: "read", + resource: { type: "envvars" }, + }); + + expect(result).toEqual({ + ok: false, + status: 401, + error: "Invalid or Missing API key", + }); + expect(rbacMocks.authenticateAuthorizeBearer).not.toHaveBeenCalled(); + }); + + it.each([ + { status: 401 as const, error: "Invalid API key" }, + { status: 403 as const, error: "Unauthorized" }, + ])("preserves controller $status failures", async (failure) => { + rbacMocks.authenticateAuthorizeBearer.mockResolvedValue({ ok: false, ...failure }); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer tr_test_key" }, + }); + + await expect( + authenticateApiKeyWithScope(request, { + action: "write", + resource: { type: "deployments" }, + }) + ).resolves.toEqual({ ok: false, ...failure }); + }); + + it("bridges controller success into the legacy private authentication shape", async () => { + const environment = { id: "env_123" }; + const ability = { can: vi.fn(() => true), canSuper: vi.fn(() => true) }; + rbacMocks.authenticateAuthorizeBearer.mockResolvedValue({ + ok: true, + environment, + ability, + subject: { type: "apiKey", apiKeyId: "key_123" }, + }); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer tr_test_key", "x-trigger-branch": "feature/test" }, + }); + + const result = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "envvars" }, + allowJWT: true, + }); + + expect(rbacMocks.authenticateAuthorizeBearer).toHaveBeenCalledWith( + request, + { action: "read", resource: { type: "envvars" } }, + { allowJWT: true } + ); + expect(result).toEqual({ + ok: true, + authentication: { + ok: true, + apiKey: "tr_test_key", + type: "PRIVATE", + environment, + ability, + }, + }); + }); +}); diff --git a/apps/webapp/test/apiBuilderAuthorization.test.ts b/apps/webapp/test/apiBuilderAuthorization.test.ts new file mode 100644 index 0000000000..be108bf279 --- /dev/null +++ b/apps/webapp/test/apiBuilderAuthorization.test.ts @@ -0,0 +1,71 @@ +import { buildJwtAbility } from "@trigger.dev/plugins"; +import { describe, expect, it } from "vitest"; +import { + checkAuth, + everyResource, + shouldRejectRestrictedKeyWithoutAuthorization, +} from "~/services/routeBuilders/apiBuilder.server"; + +describe("restricted API key route authorization", () => { + it("fails closed when a route has no authorization declaration", () => { + expect(shouldRejectRestrictedKeyWithoutAuthorization(true, false)).toBe(true); + expect(shouldRejectRestrictedKeyWithoutAuthorization(true, true)).toBe(false); + expect(shouldRejectRestrictedKeyWithoutAuthorization(false, false)).toBe(false); + }); +}); + +describe("everyResource authorization", () => { + it("requires an ID-scoped ability to match every requested resource", () => { + const ability = buildJwtAbility(["read:tasks:task-a"]); + + expect( + checkAuth( + ability, + "read", + everyResource( + [ + { type: "tasks", id: "task-a" }, + { type: "tasks", id: "task-b" }, + ], + [{ type: "runs" }, { type: "tasks" }] + ) + ) + ).toBe(false); + }); + + it("allows an ID-scoped ability when every requested resource matches", () => { + const ability = buildJwtAbility(["read:tasks:task-a", "read:tasks:task-b"]); + + expect( + checkAuth( + ability, + "read", + everyResource( + [ + { type: "tasks", id: "task-a" }, + { type: "tasks", id: "task-b" }, + ], + [{ type: "runs" }, { type: "tasks" }] + ) + ) + ).toBe(true); + }); + + it("preserves broad collection grants as an alternative", () => { + const ability = buildJwtAbility(["read:runs"]); + + expect( + checkAuth( + ability, + "read", + everyResource( + [ + { type: "tasks", id: "task-a" }, + { type: "tasks", id: "task-b" }, + ], + [{ type: "runs" }, { type: "tasks" }] + ) + ) + ).toBe(true); + }); +}); diff --git a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts index a46cd0894f..c393526b9c 100644 --- a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts +++ b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts @@ -2,6 +2,7 @@ import { postgresTest, heteroPostgresTest } from "@internal/testcontainers"; import { PostgresRunStore } from "@internal/run-store"; import type { Prisma, PrismaClient } from "@trigger.dev/database"; import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic"; +import type { RbacAbility } from "@trigger.dev/rbac"; import { beforeEach, describe, expect, vi } from "vitest"; // `resolveSchedule` reads the module-level `prisma` (control-plane handle). @@ -304,6 +305,7 @@ async function seedRunWithTree( runFriendlyId: `run_${runId}`, parentFriendlyId: `run_${parentId}`, rootFriendlyId: `run_${rootId}`, + childId, childFriendlyId: `run_${childId}`, attemptId: attempt.id, }; @@ -354,6 +356,10 @@ describe("ApiRetrieveRunPresenter.findRun store-routed read (single-DB invariant where: { id: tree.run.id }, data: { scheduleId }, }); + await prisma.taskRun.update({ + where: { id: tree.childId }, + data: { taskIdentifier: "other-task" }, + }); const env = authEnv(organization, project, runtimeEnvironment); const found = await readFoundRunViaStore(store, tree.runFriendlyId, env.id); @@ -378,6 +384,28 @@ describe("ApiRetrieveRunPresenter.findRun store-routed read (single-DB invariant expect(out.relatedRuns.root?.id).toBe(tree.rootFriendlyId); expect(out.relatedRuns.children.map((c) => c.id)).toEqual([tree.childFriendlyId]); expect(out.attemptCount).toBe(found!.attemptNumber ?? 0); + + const selectedTaskAbility: RbacAbility = { + can: (action, resource) => { + const resources = Array.isArray(resource) ? resource : [resource]; + return ( + action === "read" && + resources.some( + (candidate) => candidate.type === "tasks" && candidate.id === found!.taskIdentifier + ) + ); + }, + canSuper: () => false, + }; + const scopedOut = await new ApiRetrieveRunPresenter(CURRENT_API_VERSION).call( + found!, + env, + selectedTaskAbility + ); + + expect(scopedOut.relatedRuns.parent?.id).toBe(tree.parentFriendlyId); + expect(scopedOut.relatedRuns.root?.id).toBe(tree.rootFriendlyId); + expect(scopedOut.relatedRuns.children).toEqual([]); } ); diff --git a/apps/webapp/test/environmentVariableApiAccess.test.ts b/apps/webapp/test/environmentVariableApiAccess.test.ts new file mode 100644 index 0000000000..2e4846d218 --- /dev/null +++ b/apps/webapp/test/environmentVariableApiAccess.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const authMocks = vi.hoisted(() => ({ + authenticateRequest: vi.fn<(...args: any[]) => Promise>(), + authenticateApiKeyWithScope: vi.fn<(...args: any[]) => Promise>(), +})); + +vi.mock("~/services/apiAuth.server", () => authMocks); +vi.mock("~/services/rbac.server", () => ({ + rbac: { authenticatePat: vi.fn(), authenticateUserActor: vi.fn() }, +})); + +import { + authenticateEnvVarApiRequest, + presentedApiKeyFromAuthentication, +} from "~/services/environmentVariableApiAccess.server"; + +describe("presentedApiKeyFromAuthentication", () => { + it("returns the API key that authenticated the request", () => { + expect( + presentedApiKeyFromAuthentication({ + type: "apiKey", + result: { + ok: true, + apiKey: "tr_prod_ak_presented", + type: "PRIVATE", + environment: {} as never, + }, + }) + ).toBe("tr_prod_ak_presented"); + }); + + it("does not exchange user tokens for an API key", () => { + expect( + presentedApiKeyFromAuthentication({ + type: "personalAccessToken", + result: { userId: "user_123" } as never, + }) + ).toBeUndefined(); + }); +}); + +describe("authenticateEnvVarApiRequest", () => { + beforeEach(() => { + authMocks.authenticateRequest.mockReset(); + authMocks.authenticateApiKeyWithScope.mockReset(); + }); + + it.each([ + { type: "personalAccessToken", result: { userId: "user_123" } }, + { type: "organizationAccessToken", result: { organizationId: "org_123" } }, + ])("preserves $type authentication", async (authentication) => { + authMocks.authenticateRequest.mockResolvedValue(authentication); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer token" }, + }); + + await expect(authenticateEnvVarApiRequest(request, "read")).resolves.toEqual({ + ok: true, + authentication, + }); + expect(authMocks.authenticateRequest).toHaveBeenCalledWith(request, { + personalAccessToken: true, + organizationAccessToken: true, + apiKey: false, + }); + expect(authMocks.authenticateApiKeyWithScope).not.toHaveBeenCalled(); + }); + + it("routes API-key credentials through scoped controller authentication", async () => { + const authentication = { + ok: true, + apiKey: "tr_test_key", + type: "PRIVATE", + environment: { id: "env_123" }, + ability: { can: vi.fn(() => true) }, + }; + authMocks.authenticateRequest.mockResolvedValue(undefined); + authMocks.authenticateApiKeyWithScope.mockResolvedValue({ ok: true, authentication }); + const request = new Request("https://example.com", { + headers: { Authorization: "Bearer tr_test_key" }, + }); + + await expect(authenticateEnvVarApiRequest(request, "write")).resolves.toEqual({ + ok: true, + authentication: { type: "apiKey", result: authentication }, + }); + expect(authMocks.authenticateApiKeyWithScope).toHaveBeenCalledWith(request, { + action: "write", + resource: { type: "envvars" }, + }); + }); + + it("preserves scoped controller failures", async () => { + authMocks.authenticateRequest.mockResolvedValue(undefined); + authMocks.authenticateApiKeyWithScope.mockResolvedValue({ + ok: false, + status: 403, + error: "Unauthorized", + }); + + await expect( + authenticateEnvVarApiRequest( + new Request("https://example.com", { + headers: { Authorization: "Bearer tr_test_key" }, + }), + "read" + ) + ).resolves.toEqual({ ok: false, status: 403, error: "Unauthorized" }); + }); +}); diff --git a/apps/webapp/test/findEnvironmentByApiKey.test.ts b/apps/webapp/test/findEnvironmentByApiKey.test.ts index 5bbc1e9783..f88f3e9e2d 100644 --- a/apps/webapp/test/findEnvironmentByApiKey.test.ts +++ b/apps/webapp/test/findEnvironmentByApiKey.test.ts @@ -2,6 +2,7 @@ import { postgresTest } from "@internal/testcontainers"; import { type PrismaClient } from "@trigger.dev/database"; import { describe, expect, vi } from "vitest"; import { findEnvironmentByApiKey } from "~/models/runtimeEnvironment.server"; +import { generateAdditionalApiKey, hashApiKey } from "~/utils/apiKeys"; import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; vi.setConfig({ testTimeout: 60_000 }); @@ -162,3 +163,144 @@ describe("findEnvironmentByApiKey — non-branchable", () => { expect(resolved).toBeNull(); }); }); + +describe("findEnvironmentByApiKey — additional and disabled keys", () => { + postgresTest("authenticates an active additional key", async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const plaintext = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "External integration", + keyHash: hashApiKey(plaintext), + lastFour: plaintext.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + + const resolved = await findEnvironmentByApiKey(plaintext, undefined, prisma); + + expect(resolved?.id).toBe(environment.id); + expect(resolved?.apiKey).toBe(environment.apiKey); + }); + + postgresTest( + "rejects restricted scopes on the legacy authentication path", + async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const plaintext = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Task-scoped", + keyHash: hashApiKey(plaintext), + lastFour: plaintext.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: "TASK_SELECTION_TEST_PRESET", + scopes: ["trigger:tasks"], + }, + }); + + await expect(findEnvironmentByApiKey(plaintext, undefined, prisma)).resolves.toBeNull(); + } + ); + + postgresTest("rejects an additional key with empty scopes", async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const plaintext = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Empty policy", + keyHash: hashApiKey(plaintext), + lastFour: plaintext.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: [], + }, + }); + + await expect(findEnvironmentByApiKey(plaintext, undefined, prisma)).resolves.toBeNull(); + }); + + postgresTest("rejects revoked and expired additional keys", async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const revoked = generateAdditionalApiKey("PRODUCTION").apiKey; + const expired = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.createMany({ + data: [ + { + name: "Revoked", + keyHash: hashApiKey(revoked), + lastFour: revoked.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + revokedAt: new Date(), + }, + { + name: "Expired", + keyHash: hashApiKey(expired), + lastFour: expired.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + expiresAt: new Date(Date.now() - 1_000), + }, + ], + }); + + await expect(findEnvironmentByApiKey(revoked, undefined, prisma)).resolves.toBeNull(); + await expect(findEnvironmentByApiKey(expired, undefined, prisma)).resolves.toBeNull(); + }); + + postgresTest( + "resolves the same environment from the root key and an additional key", + async ({ prisma }) => { + const { organization, project, user } = await createTestOrgProjectWithMember(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + }); + const additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Replacement", + keyHash: hashApiKey(additional), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + + await expect( + findEnvironmentByApiKey(environment.apiKey, undefined, prisma) + ).resolves.toMatchObject({ id: environment.id }); + await expect(findEnvironmentByApiKey(additional, undefined, prisma)).resolves.toMatchObject({ + id: environment.id, + }); + } + ); +}); diff --git a/apps/webapp/test/mollifierResolveRunForMutation.test.ts b/apps/webapp/test/mollifierResolveRunForMutation.test.ts index b50d8ad940..0b18095319 100644 --- a/apps/webapp/test/mollifierResolveRunForMutation.test.ts +++ b/apps/webapp/test/mollifierResolveRunForMutation.test.ts @@ -19,11 +19,13 @@ import type { BufferEntry, MollifierBuffer } from "@trigger.dev/redis-worker"; const NOW = new Date("2026-05-21T10:00:00Z"); -function fakeReplica(row: { friendlyId: string } | null) { - return { taskRun: { findFirst: vi.fn(async () => row) } }; +function fakeReplica(row: { friendlyId: string; taskIdentifier?: string } | null) { + const result = row ? { ...row, taskIdentifier: row.taskIdentifier ?? "task-1" } : null; + return { taskRun: { findFirst: vi.fn(async () => result) } }; } -function fakeWriter(row: { friendlyId: string } | null) { - return { taskRun: { findFirst: vi.fn(async () => row) } }; +function fakeWriter(row: { friendlyId: string; taskIdentifier?: string } | null) { + const result = row ? { ...row, taskIdentifier: row.taskIdentifier ?? "task-1" } : null; + return { taskRun: { findFirst: vi.fn(async () => result) } }; } function fakeBuffer(entry: BufferEntry | null): MollifierBuffer { @@ -47,7 +49,7 @@ describe("resolveRunForMutation", () => { getBuffer: () => null, }, }); - expect(result).toEqual({ source: "pg", friendlyId: "run_1" }); + expect(result).toEqual({ source: "pg", friendlyId: "run_1", taskIdentifier: "task-1" }); }); it("returns { source: 'buffer' } when PG misses and the buffer entry matches env+org", async () => { @@ -55,7 +57,7 @@ describe("resolveRunForMutation", () => { runId: "run_1", envId: "env_a", orgId: "org_1", - payload: "{}", + payload: JSON.stringify({ taskIdentifier: "task-1" }), status: "QUEUED", attempts: 0, createdAt: NOW, @@ -71,7 +73,11 @@ describe("resolveRunForMutation", () => { getBuffer: () => fakeBuffer(entry), }, }); - expect(result).toEqual({ source: "buffer", friendlyId: "run_1" }); + expect(result).toEqual({ + source: "buffer", + friendlyId: "run_1", + taskIdentifier: "task-1", + }); }); it("returns null when PG misses and the buffer entry env doesn't match", async () => { diff --git a/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts b/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts new file mode 100644 index 0000000000..0374e49faa --- /dev/null +++ b/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authenticateEnvironmentScopedApiRequest: vi.fn<(...args: any[]) => Promise>(), + authorizePatEnvironmentAccess: vi.fn<(...args: any[]) => Promise>(), + authenticatedEnvironmentForAuthentication: vi.fn<(...args: any[]) => Promise>(), +})); + +vi.mock("~/services/environmentVariableApiAccess.server", () => ({ + authenticateEnvironmentScopedApiRequest: mocks.authenticateEnvironmentScopedApiRequest, + authorizePatEnvironmentAccess: mocks.authorizePatEnvironmentAccess, + presentedApiKeyFromAuthentication: (authentication: any) => + authentication.type === "apiKey" && authentication.result.ok + ? authentication.result.apiKey + : undefined, +})); +vi.mock("~/services/apiAuth.server", () => ({ + authenticatedEnvironmentForAuthentication: mocks.authenticatedEnvironmentForAuthentication, + branchNameFromRequest: vi.fn(() => undefined), +})); +vi.mock("~/env.server", () => ({ + env: { API_ORIGIN: "https://api.example.com", APP_ORIGIN: "https://app.example.com" }, +})); +vi.mock("~/services/logger.server", () => ({ + logger: { error: vi.fn() }, +})); + +import { loader } from "~/routes/api.v1.projects.$projectRef.$env"; + +const environment = { + id: "env_123", + apiKey: "tr_prod_root_secret", + type: "PRODUCTION", + organizationId: "org_123", + parentEnvironment: null, + parentEnvironmentId: null, + project: { + id: "proj_123", + name: "Example project", + }, +}; + +function load() { + return loader({ + request: new Request("https://app.example.com/api/v1/projects/proj_ref/prod"), + params: { projectRef: "proj_ref", env: "prod" }, + context: {}, + }); +} + +async function responseJson(response: Response) { + return response.json() as Promise>; +} + +describe("project environment credential response", () => { + beforeEach(() => { + mocks.authenticateEnvironmentScopedApiRequest.mockReset(); + mocks.authorizePatEnvironmentAccess.mockReset(); + mocks.authenticatedEnvironmentForAuthentication.mockReset(); + + mocks.authorizePatEnvironmentAccess.mockResolvedValue(undefined); + mocks.authenticatedEnvironmentForAuthentication.mockResolvedValue(environment); + }); + + it("returns the presented API key", async () => { + mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({ + ok: true, + authentication: { + type: "apiKey", + result: { + ok: true, + apiKey: "tr_prod_ak_presented", + type: "PRIVATE", + environment, + }, + }, + }); + + const response = await load(); + + expect(response.status).toBe(200); + await expect(responseJson(response)).resolves.toMatchObject({ + apiKey: "tr_prod_ak_presented", + projectId: "proj_123", + }); + }); + + it("returns the root key to an authorized user token", async () => { + mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({ + ok: true, + authentication: { + type: "personalAccessToken", + result: { userId: "user_123" }, + }, + }); + + const response = await load(); + + expect(response.status).toBe(200); + await expect(responseJson(response)).resolves.toMatchObject({ + apiKey: "tr_prod_root_secret", + }); + }); +}); diff --git a/apps/webapp/test/publicAccessTokenResponse.test.ts b/apps/webapp/test/publicAccessTokenResponse.test.ts new file mode 100644 index 0000000000..403167cfe1 --- /dev/null +++ b/apps/webapp/test/publicAccessTokenResponse.test.ts @@ -0,0 +1,49 @@ +import { validateJWT } from "@trigger.dev/core/v3/jwt"; +import { describe, expect, it } from "vitest"; +import { publicAccessTokenResponseHeaders } from "~/services/publicAccessTokenResponse.server"; + +describe("publicAccessTokenResponseHeaders", () => { + it("returns a server-signed token with the requested resource scopes", async () => { + const headers = await publicAccessTokenResponseHeaders({ + environment: { + id: "env_123", + apiKey: "tr_prod_root_signing_key", + }, + scopes: ["read:batch:batch_123"], + expirationTime: "1h", + }); + + expect(JSON.parse(headers["x-trigger-jwt-claims"]!)).toEqual({ + sub: "env_123", + pub: true, + }); + + const validation = await validateJWT(headers["x-trigger-jwt"]!, "tr_prod_root_signing_key"); + expect(validation.ok).toBe(true); + if (!validation.ok) return; + expect(validation.payload).toMatchObject({ + sub: "env_123", + pub: true, + scopes: ["read:batch:batch_123"], + }); + }); + + it("uses the parent signing key for branch environments", async () => { + const headers = await publicAccessTokenResponseHeaders({ + environment: { + id: "env_branch", + apiKey: "tr_preview_child_key", + parentEnvironment: { apiKey: "tr_preview_parent_key" }, + }, + scopes: ["write:waitpoints:waitpoint_123"], + expirationTime: "24h", + }); + + await expect( + validateJWT(headers["x-trigger-jwt"]!, "tr_preview_parent_key") + ).resolves.toMatchObject({ ok: true }); + await expect( + validateJWT(headers["x-trigger-jwt"]!, "tr_preview_child_key") + ).resolves.toMatchObject({ ok: false }); + }); +}); diff --git a/apps/webapp/test/rbacFallbackBranch.test.ts b/apps/webapp/test/rbacFallbackBranch.test.ts index f8e90e444c..b634b7cc94 100644 --- a/apps/webapp/test/rbacFallbackBranch.test.ts +++ b/apps/webapp/test/rbacFallbackBranch.test.ts @@ -1,7 +1,10 @@ import { postgresTest } from "@internal/testcontainers"; import plugin from "@trigger.dev/rbac"; +import { createHash } from "node:crypto"; +import { generateJWT } from "@trigger.dev/core/v3/jwt"; import { type PrismaClient } from "@trigger.dev/database"; import { describe, expect, vi } from "vitest"; +import { generateAdditionalApiKey } from "~/utils/apiKeys"; import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; vi.setConfig({ testTimeout: 60_000 }); @@ -143,6 +146,313 @@ describe("RBAC fallback — DEVELOPMENT branch pivot", () => { ); }); +describe("RBAC fallback — additional keys", () => { + postgresTest("authenticates an additional key and records its use", async ({ prisma }) => { + const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "External integration", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + const rootResult = await rbac.authenticateBearer(bearerRequest(environment.apiKey)); + const additionalResult = await rbac.authenticateBearer(bearerRequest(additional)); + + expect(rootResult.ok).toBe(true); + expect(additionalResult.ok).toBe(true); + if (!additionalResult.ok) return; + expect(additionalResult.environment.id).toBe(environment.id); + expect(additionalResult.environment.apiKey).toBe(environment.apiKey); + await expect( + prisma.apiKey.findFirst({ + where: { keyHash: createHash("sha256").update(additional).digest("hex") }, + select: { lastUsedAt: true }, + }) + ).resolves.toMatchObject({ lastUsedAt: expect.any(Date) }); + }); + + postgresTest("enforces restricted stored scopes on an additional key", async ({ prisma }) => { + const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Task-scoped", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: "TRIGGER_ONLY", + scopes: ["trigger:tasks:send-email"], + }, + }); + + const result = await rbac.authenticateBearer(bearerRequest(additional)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.subject).toMatchObject({ type: "apiKey", restricted: true }); + expect(result.ability.can("trigger", { type: "tasks", id: "send-email" })).toBe(true); + expect(result.ability.can("trigger", { type: "tasks", id: "other-task" })).toBe(false); + expect(result.ability.can("read", { type: "runs" })).toBe(false); + }); + + postgresTest("pivots an additional key to its branch environment", async ({ prisma }) => { + const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const devRoot = await createEnv(prisma, project.id, organization.id, { + type: "DEVELOPMENT", + orgMemberId: orgMember.id, + }); + const branch = await createEnv(prisma, project.id, organization.id, { + type: "DEVELOPMENT", + orgMemberId: orgMember.id, + parentEnvironmentId: devRoot.id, + branchName: "api-key-policy", + }); + const additional = generateAdditionalApiKey("DEVELOPMENT").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Branch key", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: devRoot.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + + const result = await rbac.authenticateBearer(bearerRequest(additional, "api-key-policy")); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.environment.id).toBe(branch.id); + expect(result.environment.parentEnvironment?.id).toBe(devRoot.id); + expect(result.subject).toMatchObject({ type: "apiKey", restricted: false }); + }); + + postgresTest("treats empty stored scopes as restricted and deny-all", async ({ prisma }) => { + const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "Empty policy", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: [], + }, + }); + + const result = await rbac.authenticateBearer(bearerRequest(additional)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.subject).toMatchObject({ type: "apiKey", restricted: true }); + expect(result.ability.can("read", { type: "runs" })).toBe(false); + expect(result.ability.can("trigger", { type: "tasks", id: "send-email" })).toBe(false); + }); +}); + +describe("RBAC fallback — public JWTs", () => { + postgresTest( + "keeps tokens signed with a rotated root key valid for the grace window", + async ({ prisma }) => { + const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const token = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "1h", + }); + + await expect( + rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }) + ).resolves.toMatchObject({ ok: true }); + + // Rotate exactly as `regenerateApiKey` does: new value on the env, old + // value parked in RevokedApiKey with a future expiry. + const previousApiKey = environment.apiKey; + await prisma.$transaction([ + prisma.revokedApiKey.create({ + data: { + apiKey: previousApiKey, + runtimeEnvironmentId: environment.id, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }), + prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { apiKey: uniqueId("tr_rotated") }, + }), + ]); + + const graceResult = await rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }); + expect(graceResult.ok).toBe(true); + if (!graceResult.ok) return; + expect(graceResult.environment.id).toBe(environment.id); + expect(graceResult.ability.can("read", { type: "runs" })).toBe(true); + expect(graceResult.ability.can("write", { type: "runs" })).toBe(false); + } + ); + + postgresTest( + "rejects a token signed with a rotated key once the grace window expires", + async ({ prisma }) => { + const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const token = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "1h", + }); + + await prisma.$transaction([ + prisma.revokedApiKey.create({ + data: { + apiKey: environment.apiKey, + runtimeEnvironmentId: environment.id, + expiresAt: new Date(Date.now() - 60 * 1000), + }, + }), + prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { apiKey: uniqueId("tr_rotated") }, + }), + ]); + + await expect( + rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }) + ).resolves.toMatchObject({ ok: false, status: 401 }); + } + ); + + postgresTest("surfaces public JWT actor attribution", async ({ prisma }) => { + const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const token = await generateJWT({ + secretKey: environment.apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["read:runs"], + act: { sub: user.id }, + }, + expirationTime: "1h", + }); + + const result = await rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.jwt?.act).toEqual({ sub: user.id }); + }); + + postgresTest("rejects public JWTs for soft-deleted projects", async ({ prisma }) => { + const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const token = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "1h", + }); + await prisma.project.update({ where: { id: project.id }, data: { deletedAt: new Date() } }); + + const result = await rbac.authenticateBearer(bearerRequest(token), { allowJWT: true }); + + expect(result).toMatchObject({ ok: false, status: 401 }); + }); +}); + +describe("RBAC fallback — additional key permissions", () => { + postgresTest( + "gives additional keys root-key-equivalent permissive access", + async ({ prisma }) => { + const { organization, project, orgMember, user } = + await createTestOrgProjectWithMember(prisma); + const rbac = makeController(prisma); + const environment = await createEnv(prisma, project.id, organization.id, { + type: "PRODUCTION", + orgMemberId: orgMember.id, + }); + const additional = generateAdditionalApiKey("PRODUCTION").apiKey; + + await prisma.apiKey.create({ + data: { + name: "External integration", + keyHash: createHash("sha256").update(additional).digest("hex"), + lastFour: additional.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: null, + scopes: ["admin"], + }, + }); + + const rootResult = await rbac.authenticateBearer(bearerRequest(environment.apiKey)); + const additionalResult = await rbac.authenticateBearer(bearerRequest(additional)); + + expect(rootResult.ok).toBe(true); + expect(additionalResult.ok).toBe(true); + if (!rootResult.ok || !additionalResult.ok) return; + + expect(additionalResult.subject).toMatchObject({ type: "apiKey", restricted: false }); + for (const [action, resource] of [ + ["write", { type: "envvars" }], + ["trigger", { type: "tasks", id: "send-email" }], + ["read", { type: "runs", id: "run_123" }], + ] as const) { + expect(additionalResult.ability.can(action, resource)).toBe( + rootResult.ability.can(action, resource) + ); + } + } + ); +}); + describe("RBAC fallback — branch header guards", () => { // The "default" sentinel is DEVELOPMENT-only: it maps the dev root env to its // (branchless) self. For PREVIEW, "default" is an ordinary branch name, so a diff --git a/apps/webapp/test/streamBatchItemsAuthorization.test.ts b/apps/webapp/test/streamBatchItemsAuthorization.test.ts new file mode 100644 index 0000000000..0ea800a104 --- /dev/null +++ b/apps/webapp/test/streamBatchItemsAuthorization.test.ts @@ -0,0 +1,136 @@ +import { buildJwtAbility } from "@trigger.dev/plugins"; +import { describe, expect, it } from "vitest"; +import { withActionAliases } from "@trigger.dev/rbac"; +import { + authorizeBatchItems, + authorizedBatchItemStream, + batchPublicAccessScopes, +} from "~/utils/batchItemAuthorization"; +import { canWriteResolvedParentRun } from "~/utils/parentRunAuthorization"; + +async function collect(items: AsyncIterable): Promise { + const result: unknown[] = []; + for await (const item of items) result.push(item); + return result; +} + +async function* batchItems(...tasks: string[]): AsyncIterable { + for (const task of tasks) yield { task, payload: {} }; +} + +describe("streaming batch item authorization", () => { + it("rejects a selected-task key when any streamed task is unauthorized", async () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + await expect( + collect(authorizeBatchItems(batchItems("task-a", "task-b"), ability, "batch_123")) + ).rejects.toThrow(); + }); + + it("allows all items covered by the key's task grants", async () => { + const ability = withActionAliases( + buildJwtAbility(["batchTrigger:tasks:task-a", "batchTrigger:tasks:task-b"]) + ); + + await expect( + collect(authorizeBatchItems(batchItems("task-a", "task-b"), ability, "batch_123")) + ).resolves.toHaveLength(2); + }); + + it("preserves a public JWT's write grant for the batch", async () => { + const ability = withActionAliases(buildJwtAbility(["write:batch:batch_123"])); + + await expect( + collect(authorizeBatchItems(batchItems("task-a", "task-b"), ability, "batch_123")) + ).resolves.toHaveLength(2); + }); + + it("does not delegate batch-wide writes from selected-task credentials", () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + expect(batchPublicAccessScopes("batch_123", ability, true)).toEqual(["read:batch:batch_123"]); + }); + + it("delegates batch-wide writes when the credential can trigger every task", () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks"])); + + expect(batchPublicAccessScopes("batch_123", ability, true)).toEqual([ + "read:batch:batch_123", + "write:batch:batch_123", + ]); + }); + + it("rejects an empty stream from a credential with no batch-level write grant", async () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + // An empty stream authorizes nothing, so it must not reach the service — + // whose batch lookup would otherwise report the batch's existence and status. + await expect(authorizedBatchItemStream(batchItems(), ability, "batch_123")).rejects.toThrow(); + }); + + it("allows an empty stream from a credential that can write the batch", async () => { + const ability = withActionAliases(buildJwtAbility(["write:batch:batch_123"])); + + const stream = await authorizedBatchItemStream(batchItems(), ability, "batch_123"); + + await expect(collect(stream)).resolves.toHaveLength(0); + }); + + it("still authorizes every item when the first one passes", async () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + const stream = await authorizedBatchItemStream( + batchItems("task-a", "task-b"), + ability, + "batch_123" + ); + + await expect(collect(stream)).rejects.toThrow(); + }); + + it("replays the eagerly-pulled first item to the consumer", async () => { + const ability = withActionAliases(buildJwtAbility(["batchTrigger:tasks:task-a"])); + + const stream = await authorizedBatchItemStream( + batchItems("task-a", "task-a"), + ability, + "batch_123" + ); + + await expect(collect(stream)).resolves.toEqual([ + { task: "task-a", payload: {} }, + { task: "task-a", payload: {} }, + ]); + }); + + it("treats a type-level write:runs grant as covering every parent run", () => { + // The route-level `canWriteParentRun` short-circuits on this, skipping a DB + // lookup per distinct parent. Assert the premise holds: the grant really + // does authorize an arbitrary run id. + const ability = withActionAliases(buildJwtAbility(["write:runs"])); + + expect( + canWriteResolvedParentRun(ability, { + friendlyId: "run_anything", + taskIdentifier: "some-task-we-have-no-grant-for", + }) + ).toBe(true); + }); + + it("only allows selected-task operators to link parent runs for their tasks", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks:task-a"])); + + expect( + canWriteResolvedParentRun(ability, { + friendlyId: "run_a", + taskIdentifier: "task-a", + }) + ).toBe(true); + expect( + canWriteResolvedParentRun(ability, { + friendlyId: "run_b", + taskIdentifier: "task-b", + }) + ).toBe(false); + }); +}); diff --git a/internal-packages/rbac/src/apiKeyPolicies.test.ts b/internal-packages/rbac/src/apiKeyPolicies.test.ts new file mode 100644 index 0000000000..4344f9c63b --- /dev/null +++ b/internal-packages/rbac/src/apiKeyPolicies.test.ts @@ -0,0 +1,212 @@ +import { extractJWTSub, isPublicJWT } from "@trigger.dev/core/v3/jwt"; +import type { PrismaClient } from "@trigger.dev/database"; +import { + scopesGrantFullAccess, + type BearerAuthResult, + type RoleBaseAccessController, +} from "@trigger.dev/plugins"; +import { describe, expect, it, vi } from "vitest"; +import { buildJwtAbility } from "./ability.js"; +import loader, { resolveJwtSigningKey } from "./index.js"; + +type AuthSuccess = Extract; + +type LazyControllerInternals = { + _init: Promise; + _hostCredentialResolver: { + authenticate: RoleBaseAccessController["authenticateBearer"]; + }; +}; + +const prismaPlaceholder = {} as PrismaClient; +const environment = { + id: "env_123", + organizationId: "org_123", + projectId: "proj_123", + parentEnvironment: null, + parentEnvironmentId: null, +} as unknown as AuthSuccess["environment"]; + +function installPlugin( + plugin: RoleBaseAccessController, + hostAuthenticate: RoleBaseAccessController["authenticateBearer"] +) { + const controller = loader.create(prismaPlaceholder, { forceFallback: true }); + const internals = controller as unknown as LazyControllerInternals; + internals._init = Promise.resolve(plugin); + internals._hostCredentialResolver = { authenticate: hostAuthenticate }; + return controller; +} + +function additionalKeyResult(scopes: string[]): AuthSuccess { + return { + ok: true, + environment, + subject: { + type: "apiKey", + apiKeyId: "key_123", + restricted: !scopesGrantFullAccess(scopes), + organizationId: environment.organizationId, + projectId: environment.projectId, + }, + ability: buildJwtAbility(scopes), + }; +} + +function publicJwtResult(): AuthSuccess { + return { + ok: true, + environment, + subject: { + type: "publicJWT", + environmentId: environment.id, + organizationId: environment.organizationId, + projectId: environment.projectId, + }, + ability: buildJwtAbility(["read:runs"]), + }; +} + +function publicJwt(payload: Record) { + return `header.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.signature`; +} + +describe("API-key policy controller composition", () => { + it("routes public JWTs directly to the host without calling the plugin authenticator", async () => { + const pluginAuthenticate = vi.fn(); + const hostAuthenticate = vi.fn(async () => publicJwtResult()); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + const token = publicJwt({ pub: true, sub: environment.id }); + + const result = await controller.authenticateBearer( + new Request("https://api.trigger.dev/test", { + headers: { Authorization: `Bearer ${token}` }, + }), + { allowJWT: true } + ); + + expect(result.ok).toBe(true); + expect(hostAuthenticate).toHaveBeenCalledOnce(); + expect(pluginAuthenticate).not.toHaveBeenCalled(); + }); + + it("uses the scoped host result unchanged when plugin auth falls back to an additional key", async () => { + const pluginAuthenticate = vi.fn(async () => ({ + ok: false as const, + status: 401 as const, + error: "Invalid API key", + })); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin( + plugin, + vi.fn(async () => additionalKeyResult(["write:tasks:send-email"])) + ); + + const result = await controller.authenticateBearer( + new Request("https://api.trigger.dev/test", { + headers: { Authorization: "Bearer tr_additional" }, + }) + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.subject).toMatchObject({ type: "apiKey", restricted: true }); + expect(result.ability.can("trigger", { type: "tasks", id: "send-email" })).toBe(true); + expect(result.ability.can("trigger", { type: "tasks", id: "other-task" })).toBe(false); + expect(result.ability.can("read", { type: "runs" })).toBe(false); + }); + + it("delegates API-key policy catalogue, preparation, and description", async () => { + const presets = vi.fn(async () => []); + const prepare = vi.fn(async () => ({ + ok: true as const, + policy: { presetId: "FULL_ACCESS", scopes: ["admin"] }, + })); + const describePolicy = vi.fn(async () => ({ taskIdentifiers: ["send-email"] })); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + apiKeyPresets: presets, + prepareApiKeyPolicy: prepare, + describeApiKeyPolicy: describePolicy, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, vi.fn()); + const prepareParams = { organizationId: "org_123", presetId: "FULL_ACCESS" }; + const policy = { presetId: "TASKS", scopes: ["trigger:tasks:send-email"] }; + + await controller.apiKeyPresets("org_123"); + await controller.prepareApiKeyPolicy(prepareParams); + await controller.describeApiKeyPolicy(policy); + + expect(presets).toHaveBeenCalledWith("org_123"); + expect(prepare).toHaveBeenCalledWith(prepareParams); + expect(describePolicy).toHaveBeenCalledWith(policy); + }); +}); + +describe("API-key policy fallback", () => { + it("prepares explicit standalone full access and exposes no preset catalogue", async () => { + const controller = loader.create(prismaPlaceholder, { forceFallback: true }); + + await expect(controller.apiKeyPresets("org_123")).resolves.toBeNull(); + await expect( + controller.prepareApiKeyPolicy({ organizationId: "org_123", presetId: "FULL_ACCESS" }) + ).resolves.toEqual({ + ok: true, + policy: { presetId: null, scopes: ["admin"] }, + }); + await expect( + controller.describeApiKeyPolicy({ presetId: null, scopes: ["admin"] }) + ).resolves.toEqual({}); + }); + + it("rejects restricted presets and task input without a plugin", async () => { + const controller = loader.create(prismaPlaceholder, { forceFallback: true }); + const unavailable = { ok: false, error: "API key access presets are not available" }; + + // Anything other than full access is a restricted key, which needs the plugin. + await expect( + controller.prepareApiKeyPolicy({ organizationId: "org_123", presetId: "TRIGGER_ONLY" }) + ).resolves.toEqual(unavailable); + await expect( + controller.prepareApiKeyPolicy({ + organizationId: "org_123", + presetId: "FULL_ACCESS", + taskIdentifiers: ["send-email"], + }) + ).resolves.toEqual(unavailable); + }); +}); + +describe("scope policy helpers", () => { + it("recognizes only exact bare admin as full access", () => { + expect(scopesGrantFullAccess(["admin"])).toBe(true); + expect(scopesGrantFullAccess(["read:runs", "admin"])).toBe(true); + expect(scopesGrantFullAccess([])).toBe(false); + expect(scopesGrantFullAccess(["admin:runs"])).toBe(false); + expect(scopesGrantFullAccess(["*:all"])).toBe(false); + }); +}); + +describe("JWT host helpers", () => { + it("recognizes public JWTs and extracts their subject", () => { + const token = publicJwt({ pub: true, sub: "env_123" }); + expect(isPublicJWT(token)).toBe(true); + expect(extractJWTSub(token)).toBe("env_123"); + expect(isPublicJWT("not-a-jwt")).toBe(false); + expect(extractJWTSub("not-a-jwt")).toBeUndefined(); + }); + + it("uses the parent key as branch JWT-signing material", () => { + expect(resolveJwtSigningKey({ apiKey: "root" })).toBe("root"); + expect(resolveJwtSigningKey({ apiKey: "child", parentEnvironment: { apiKey: "parent" } })).toBe( + "parent" + ); + }); +}); diff --git a/internal-packages/rbac/src/bearerCredentials.ts b/internal-packages/rbac/src/bearerCredentials.ts new file mode 100644 index 0000000000..fb2d6b7e5c --- /dev/null +++ b/internal-packages/rbac/src/bearerCredentials.ts @@ -0,0 +1,299 @@ +import type { BearerAuthResult, RbacEnvironment, RbacSubject } from "@trigger.dev/plugins"; +import { createHash } from "node:crypto"; +import type { PrismaClient } from "@trigger.dev/database"; +import { extractJWTSub, isPublicJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; +import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { scopesGrantFullAccess } from "@trigger.dev/plugins"; +import { buildJwtAbility, permissiveAbility } from "./ability.js"; + +export type BearerCredentialClients = { + // Used for the `lastUsedAt` telemetry write; reads go to the replica. + primary: PrismaClient; + replica: PrismaClient; +}; + +// JWT-signing material. Today this is the environment's root apiKey (or the +// parent's, for branch envs) — which is why rotating a root key needs the +// grace-window retry in `authenticate`. When a dedicated per-environment +// signing secret lands, this is the only credential-resolution seam that needs +// to change. +export function resolveJwtSigningKey(env: { + apiKey: string; + parentEnvironment?: { apiKey: string } | null; +}): string { + return env.parentEnvironment?.apiKey ?? env.apiKey; +} + +/** + * Resolves an incoming bearer token into a `BearerAuthResult`: a public JWT, a + * root environment API key, a grace-window rotated key, or a host-owned + * additional API key (the `ApiKey` table). + * + * This is deliberately NOT part of the "no-plugin fallback". Additional API + * keys are owned by the host, not by the optional RBAC plugin, so this + * resolution is always-on and is composed by *both* the fallback controller + * and the plugin-backed controller (which delegates host-owned keys here). + * + * Public JWTs carry inline scopes, which are compiled into an ability here. + * Additional API-key credentials carry host-persisted effective scopes, which + * are compiled into their final ability here without a plugin policy lookup. + */ +export class BearerCredentialResolver { + private readonly prisma: PrismaClient; + private readonly replica: PrismaClient; + + constructor(clients: BearerCredentialClients) { + this.prisma = clients.primary; + this.replica = clients.replica; + } + + async authenticate( + request: Request, + options?: { allowJWT?: boolean } + ): Promise { + // Deprecated public API keys (`pk_*` minted long before public JWTs + // landed) are intentionally NOT handled here. That token format hasn't + // been issued for years; any `pk_*` bearer on an apiBuilder route returns + // 401. Public access goes through the JWT path (`isPublicJWT`) instead. + const rawToken = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + if (!rawToken) return { ok: false, status: 401, error: "Invalid or Missing API key" }; + + if (options?.allowJWT && isPublicJWT(rawToken)) { + const envId = extractJWTSub(rawToken); + if (!envId) return { ok: false, status: 401, error: "Invalid Public Access Token" }; + + // Match the include shape of the slim AuthenticatedEnvironment so + // the bridge can use the returned env without a follow-up fetch. + const env = await this.replica.runtimeEnvironment.findFirst({ + where: { id: envId }, + include: { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, + parentEnvironment: { + select: { id: true, apiKey: true }, + }, + }, + }); + if (!env || env.project.deletedAt !== null) { + return { ok: false, status: 401, error: "Invalid Public Access Token" }; + } + + let result = await validateJWT(rawToken, resolveJwtSigningKey(env)); + + // Root-key rotation grace window, mirroring the bearer path below: a + // rotated key keeps authenticating until its `RevokedApiKey` row expires, + // so tokens *signed* with that key have to keep verifying for just as + // long. Otherwise rotating a root key silently invalidates every + // outstanding public access token in the environment. + // + // Only retried on a signature mismatch — an expired or malformed token + // fails for a reason no other signing key can fix, and this runs on every + // rejected public token. + if (!result.ok && result.code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") { + // `resolveJwtSigningKey` signs a branch with its parent's key, so the + // grace-window rows to consult belong to whichever env owns that key. + const signingEnvironmentId = env.parentEnvironment?.id ?? env.id; + const revoked = await this.replica.revokedApiKey.findMany({ + where: { + runtimeEnvironmentId: signingEnvironmentId, + expiresAt: { gt: new Date() }, + }, + select: { apiKey: true }, + }); + + for (const candidate of revoked) { + const retried = await validateJWT(rawToken, candidate.apiKey); + if (retried.ok) { + result = retried; + break; + } + } + } + + if (!result.ok) return { ok: false, status: 401, error: "Public Access Token is invalid" }; + + const scopes = Array.isArray(result.payload.scopes) + ? (result.payload.scopes as string[]) + : []; + const realtime = result.payload.realtime as { skipColumns?: string[] } | undefined; + const oneTimeUse = result.payload.otu === true; + // A JWT minted from a PAT/UAT exchange stamps `act: { sub: userId }` for + // attribution. Surface it so write handlers can record the acting user. + const act = result.payload.act as { sub?: unknown } | undefined; + const actSub = typeof act?.sub === "string" ? act.sub : undefined; + + return { + ok: true, + environment: toAuthenticatedEnvironment(env), + subject: { + type: "publicJWT", + environmentId: env.id, + organizationId: env.organizationId, + projectId: env.projectId, + }, + ability: buildJwtAbility(scopes), + jwt: { realtime, oneTimeUse, ...(actSub ? { act: { sub: actSub } } : {}) }, + }; + } + + // PREVIEW (and DEVELOPMENT) envs are parents — operating "on a branch" means routing + // to a child env keyed by branchName. The customer authenticates + // with the parent's apiKey + an `x-trigger-branch` header. Include the + // matching child env so the pivot below can adopt its identity. + const branchName = sanitizeBranchName(request.headers.get("x-trigger-branch")); + const include = { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, + parentEnvironment: { select: { id: true, apiKey: true } }, + childEnvironments: branchName ? { where: { branchName, archivedAt: null } } : undefined, + } as const; + const now = new Date(); + let additionalApiKey: { id: string; lastUsedAt: Date | null; scopes: string[] } | null = null; + let env = await this.replica.runtimeEnvironment.findFirst({ + where: { apiKey: rawToken }, + include, + }); + + // Revoked API key grace window — recently rotated keys keep working until + // their `expiresAt`; without this a customer who rotates an env API key + // gets immediate 401s on the new auth path. + if (!env) { + const revoked = await this.replica.revokedApiKey.findFirst({ + where: { + apiKey: rawToken, + expiresAt: { gt: now }, + }, + include: { runtimeEnvironment: { include } }, + }); + env = revoked?.runtimeEnvironment ?? null; + } + + if (!env) { + const match = await this.replica.apiKey.findFirst({ + where: { + keyHash: createHash("sha256").update(rawToken, "utf8").digest("hex"), + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + select: { + id: true, + lastUsedAt: true, + scopes: true, + runtimeEnvironment: { include }, + }, + }); + + additionalApiKey = match + ? { id: match.id, lastUsedAt: match.lastUsedAt, scopes: match.scopes } + : null; + env = match?.runtimeEnvironment ?? null; + } + + if (!env || env.project.deletedAt !== null) { + return { ok: false, status: 401, error: "Invalid API key" }; + } + + if ( + additionalApiKey && + (!additionalApiKey.lastUsedAt || + additionalApiKey.lastUsedAt < new Date(now.getTime() - 300_000)) + ) { + try { + await this.prisma.apiKey.updateMany({ + where: { + id: additionalApiKey.id, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + data: { lastUsedAt: now }, + }); + } catch { + // Authentication should not fail because last-used telemetry could not be recorded. + } + } + + if (env.type === "PREVIEW" && !branchName) { + return { + ok: false, + status: 401, + error: "x-trigger-branch header required for preview env", + }; + } + + if (env.type === "PREVIEW" || env.type === "DEVELOPMENT") { + // The "default" root branch is DEVELOPMENT-only: it maps to the dev root env + // (which carries no branch), so we skip the pivot there. For PREVIEW, + // "default" is an ordinary branch name and must still pivot to its child. + const isDevAndDefault = env.type === "DEVELOPMENT" && isDefaultDevBranch(branchName); + if (branchName !== null && !isDevAndDefault) { + const child = env.childEnvironments?.[0]; + if (!child) { + return { ok: false, status: 401, error: "No matching branch env" }; + } + // Pivot to the child env: child's id/type/branchName, parent's + // apiKey/orgMember/organization/project. + env = { + ...child, + apiKey: env.apiKey, + orgMember: env.orgMember, + organization: env.organization, + project: env.project, + parentEnvironment: { id: env.id, apiKey: env.apiKey }, + childEnvironments: [], + }; + } + } + + // An additional (ApiKey-table) key is a first-class `apiKey` principal. + // Root/legacy environment keys keep the `user` subject (they're on their + // way out once additional keys fully replace them). + const subject: RbacSubject = additionalApiKey + ? { + type: "apiKey", + apiKeyId: additionalApiKey.id, + restricted: !scopesGrantFullAccess(additionalApiKey.scopes), + organizationId: env.organizationId, + projectId: env.projectId, + } + : { + type: "user", + userId: env.orgMember?.userId ?? "", + organizationId: env.organizationId, + projectId: env.projectId, + }; + + return { + ok: true, + environment: toAuthenticatedEnvironment(env), + subject, + ability: additionalApiKey ? buildJwtAbility(additionalApiKey.scopes) : permissiveAbility, + }; + } +} + +// Coerce a Prisma RuntimeEnvironment payload (with project/organization/ +// orgMember/parentEnvironment includes) into the slim AuthenticatedEnvironment +// the auth contract carries. Explicit coercion keeps +// `concurrencyLimitBurstFactor` a plain number across the auth boundary. +function toAuthenticatedEnvironment(env: RbacEnvironment): RbacEnvironment { + const burst = env.concurrencyLimitBurstFactor; + return { + ...env, + concurrencyLimitBurstFactor: typeof burst === "number" ? burst : burst.toNumber(), + }; +} diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index 4cf1e765c7..d1d22f3b09 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -1,7 +1,6 @@ import type { Permission, Role, - RbacEnvironment, RbacUser, RbacSubject, RbacResource, @@ -20,9 +19,8 @@ import { } from "@trigger.dev/plugins"; import { createHash } from "node:crypto"; import type { PrismaClient } from "@trigger.dev/database"; -import { validateJWT } from "@trigger.dev/core/v3/jwt"; -import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; -import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js"; +import { buildFallbackAbility, permissiveAbility } from "./ability.js"; +import { BearerCredentialResolver } from "./bearerCredentials.js"; export type FallbackPrismaClients = { // Used for writes (setUserRole, mutateRole, etc.) and any reads that @@ -68,11 +66,15 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { private readonly prisma: PrismaClient; // alias for primary — used by writes private readonly replica: PrismaClient; private readonly userActorSecret?: string; + // Bearer-token resolution (JWTs, root keys, additional API keys) is always-on + // host logic, not an RBAC default — see bearerCredentials.ts. + private readonly bearer: BearerCredentialResolver; constructor(clients: FallbackPrismaClients, options?: FallbackOptions) { this.prisma = clients.primary; this.replica = clients.replica; this.userActorSecret = options?.userActorSecret; + this.bearer = new BearerCredentialResolver(clients); } async isUsingPlugin(): Promise { @@ -83,166 +85,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { request: Request, options?: { allowJWT?: boolean } ): Promise { - // Deprecated public API keys (`pk_*` minted long before public JWTs - // landed) are intentionally NOT handled here. The legacy - // `findEnvironmentByPublicApiKey` path looked them up via the - // `pkApiKey` column, but that token format hasn't been issued for - // years and no live client should be sending one. Any `pk_*` bearer - // on a route that goes through the apiBuilder now returns 401 — - // public access goes through the JWT path (`isPublicJWT(rawToken)` - // below) instead. The deprecated lookup is still exported from - // `apps/webapp/app/models/runtimeEnvironment.server.ts` for the - // pre-RBAC routes that haven't been migrated, but it's a dead - // code path for any route that uses `createLoaderApiRoute` / - // `createActionApiRoute`. - const rawToken = request.headers - .get("Authorization") - ?.replace(/^Bearer /, "") - .trim(); - if (!rawToken) return { ok: false, status: 401, error: "Invalid or Missing API key" }; - - if (options?.allowJWT && isPublicJWT(rawToken)) { - const envId = extractJWTSub(rawToken); - if (!envId) return { ok: false, status: 401, error: "Invalid Public Access Token" }; - - // Match the include shape of the slim AuthenticatedEnvironment so - // the bridge can use the returned env without a follow-up fetch. - const env = await this.replica.runtimeEnvironment.findFirst({ - where: { id: envId }, - include: { - project: true, - organization: true, - orgMember: { - select: { - userId: true, - user: { select: { id: true, displayName: true, name: true } }, - }, - }, - parentEnvironment: { select: { id: true, apiKey: true } }, - }, - }); - if (!env || env.project.deletedAt !== null) { - return { ok: false, status: 401, error: "Invalid Public Access Token" }; - } - - const signingKey = env.parentEnvironment?.apiKey ?? env.apiKey; - const result = await validateJWT(rawToken, signingKey); - if (!result.ok) return { ok: false, status: 401, error: "Public Access Token is invalid" }; - - const scopes = Array.isArray(result.payload.scopes) - ? (result.payload.scopes as string[]) - : []; - const realtime = result.payload.realtime as { skipColumns?: string[] } | undefined; - const oneTimeUse = result.payload.otu === true; - // A JWT minted from a PAT/UAT exchange stamps `act: { sub: userId }` for - // attribution. Surface it so write handlers can record the acting user. - const act = result.payload.act as { sub?: unknown } | undefined; - const actSub = typeof act?.sub === "string" ? act.sub : undefined; - - return { - ok: true, - environment: toAuthenticatedEnvironment(env), - subject: { - type: "publicJWT", - environmentId: env.id, - organizationId: env.organizationId, - projectId: env.projectId, - }, - ability: buildJwtAbility(scopes), - jwt: { realtime, oneTimeUse, ...(actSub ? { act: { sub: actSub } } : {}) }, - }; - } - - // PREVIEW (and DEVELOPMENT) envs are parents — operating "on a branch" means routing - // to a child env keyed by branchName. The customer authenticates - // with the parent's apiKey + an `x-trigger-branch` header. Mirror - // findEnvironmentByApiKey: include the matching child env so the - // pivot below can adopt its identity. - const branchName = sanitizeBranchName(request.headers.get("x-trigger-branch")); - // Match the include shape of the slim AuthenticatedEnvironment so - // the apiBuilder bridge can use the returned env directly without a - // follow-up findEnvironmentById call. - const include = { - project: true, - organization: true, - orgMember: { - select: { - userId: true, - user: { select: { id: true, displayName: true, name: true } }, - }, - }, - parentEnvironment: { select: { id: true, apiKey: true } }, - childEnvironments: branchName ? { where: { branchName, archivedAt: null } } : undefined, - } as const; - let env = await this.replica.runtimeEnvironment.findFirst({ - where: { apiKey: rawToken }, - include, - }); - - // Revoked API key grace window — mirrors `findEnvironmentByApiKey` - // in apps/webapp/app/models/runtimeEnvironment.server.ts. Recently - // rotated keys keep working until their `expiresAt`; without this - // branch a customer who rotates an env API key gets immediate 401s - // on the new auth path. The PR's e2e suite covers this in - // auth-cross-cutting.e2e.full.test.ts ("revoked key within grace"). - if (!env) { - const revoked = await this.replica.revokedApiKey.findFirst({ - where: { apiKey: rawToken, expiresAt: { gt: new Date() } }, - include: { runtimeEnvironment: { include } }, - }); - env = revoked?.runtimeEnvironment ?? null; - } - - if (!env || env.project.deletedAt !== null) { - return { ok: false, status: 401, error: "Invalid API key" }; - } - - if (env.type === "PREVIEW" && !branchName) { - return { - ok: false, - status: 401, - error: "x-trigger-branch header required for preview env", - }; - } - - if (env.type === "PREVIEW" || env.type === "DEVELOPMENT") { - // The "default" root branch is DEVELOPMENT-only: it maps to the dev root env - // (which carries no branch), so we skip the pivot there. For PREVIEW, - // "default" is an ordinary branch name and must still pivot to its child. - const isDevAndDefault = env.type === "DEVELOPMENT" && isDefaultDevBranch(branchName); - if (branchName !== null && !isDevAndDefault) { - const child = env.childEnvironments?.[0]; - if (!child) { - return { ok: false, status: 401, error: "No matching branch env" }; - } - // Pivot to the child env: child's id/type/branchName, parent's - // apiKey/orgMember/organization/project. parentEnvironment is set - // explicitly here so the slim shape stays internally consistent. - env = { - ...child, - apiKey: env.apiKey, - orgMember: env.orgMember, - organization: env.organization, - project: env.project, - parentEnvironment: { id: env.id, apiKey: env.apiKey }, - childEnvironments: [], - }; - } - } - - const subject: RbacSubject = { - type: "user", - userId: env.orgMember?.userId ?? "", - organizationId: env.organizationId, - projectId: env.projectId, - }; - - return { - ok: true, - environment: toAuthenticatedEnvironment(env), - subject, - ability: permissiveAbility, - }; + return this.bearer.authenticate(request, options); } async authenticateSession( @@ -388,14 +231,14 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { taskIdentifiers?: string[]; }) { // Without a plugin there is no preset catalogue, so full access is the only - // policy on offer, but the caller still has to ask for it by name. Any + // policy on offer — but the caller still has to ask for it by name. Any // other preset, or any task selection, is a restricted key and unavailable. if (params.presetId !== FULL_ACCESS_PRESET_ID || (params.taskIdentifiers?.length ?? 0) > 0) { return { ok: false as const, error: "API key access presets are not available" }; } - // `presetId: null` because this install has no catalogue to reference. The - // persisted scopes remain the source of truth for authorization. + // `presetId: null` because this install has no catalogue to reference — the + // key is full-access, not an instance of a named preset. return { ok: true as const, policy: { presetId: null, scopes: ["admin"] }, @@ -463,48 +306,6 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { } } -function isPublicJWT(token: string): boolean { - const parts = token.split("."); - if (parts.length !== 3) return false; - try { - const payload = JSON.parse( - Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8") - ); - return payload !== null && typeof payload === "object" && payload.pub === true; - } catch { - return false; - } -} - -function extractJWTSub(token: string): string | undefined { - const parts = token.split("."); - if (parts.length !== 3) return undefined; - try { - const payload = JSON.parse( - Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8") - ); - return payload !== null && typeof payload === "object" && typeof payload.sub === "string" - ? payload.sub - : undefined; - } catch { - return undefined; - } -} - -// Coerce a Prisma RuntimeEnvironment payload (with project/organization/ -// orgMember/parentEnvironment includes) into the slim AuthenticatedEnvironment -// the auth contract carries. The slim type accepts both `number` and -// Decimal-like for `concurrencyLimitBurstFactor`, but explicit coercion -// here keeps the value a plain number across the auth boundary so -// downstream consumers don't have to narrow before doing arithmetic. -function toAuthenticatedEnvironment(env: RbacEnvironment): RbacEnvironment { - const burst = env.concurrencyLimitBurstFactor; - return { - ...env, - concurrencyLimitBurstFactor: typeof burst === "number" ? burst : burst.toNumber(), - }; -} - function toRbacUser(user: { id: string; email: string; diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index 6873c9d479..e55b5b682e 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -13,7 +13,10 @@ import type { RoleMutationResult, } from "@trigger.dev/plugins"; import type { PrismaClient } from "@trigger.dev/database"; +import { isPublicJWT } from "@trigger.dev/core/v3/jwt"; + import { RoleBaseAccessFallback } from "./fallback.js"; +import { BearerCredentialResolver } from "./bearerCredentials.js"; export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins"; /** @@ -30,6 +33,7 @@ export type HostRbacController = Required; export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins"; export { buildJwtAbility, scopesWithinAbility } from "./ability.js"; export { FULL_ACCESS_PRESET_ID, scopesGrantFullAccess } from "@trigger.dev/plugins"; +export { resolveJwtSigningKey } from "./bearerCredentials.js"; // Re-export the user-actor token grammar so the webapp mints/checks tokens // through @trigger.dev/rbac (it doesn't import @trigger.dev/plugins directly). export { @@ -85,8 +89,15 @@ export function withActionAliases(underlying: RbacAbility): RbacAbility { // Synchronous create() avoids top-level await (not supported in the webapp's CJS build). class LazyController implements RoleBaseAccessController { private readonly _init: Promise; + // Additional API keys (the ApiKey table) are host-owned, not known to the + // optional plugin. The host resolves them with its always-on credential + // resolver — not a full RBAC fallback controller. + private readonly _hostCredentialResolver: BearerCredentialResolver; constructor(prisma: RbacPrismaInput, options?: RbacCreateOptions) { + this._hostCredentialResolver = new BearerCredentialResolver( + "primary" in prisma ? prisma : { primary: prisma, replica: prisma } + ); this._init = this.load(prisma, options); // load() runs eagerly but the result is awaited lazily on first method // call. If load() rejects (e.g. REQUIRE_PLUGINS=1 + plugin missing) and @@ -176,7 +187,32 @@ class LazyController implements RoleBaseAccessController { } async authenticateBearer(...args: Parameters) { - const result = await (await this.c()).authenticateBearer(...args); + const controller = await this.c(); + const usingPlugin = await controller.isUsingPlugin(); + const [request, options] = args; + const rawToken = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + const useHostForPublicJWT = Boolean(options?.allowJWT && rawToken && isPublicJWT(rawToken)); + + // Public JWT validation is host-owned. Route it directly to the host so + // plugin implementations cannot drift from the canonical JWT checks. + let result = useHostForPublicJWT + ? await this._hostCredentialResolver.authenticate(...args) + : await controller.authenticateBearer(...args); + + // Additional environment API keys are stored by the host, not by the + // optional RBAC plugin. If the plugin does not recognize a bearer token, + // let the host resolver try that principal type. Root keys and all + // plugin-owned principals remain authoritative in the plugin. + if (!useHostForPublicJWT && !result.ok && result.status === 401 && usingPlugin) { + const hostResult = await this._hostCredentialResolver.authenticate(...args); + if (hostResult.ok && hostResult.subject.type === "apiKey") { + result = hostResult; + } + } + return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; } From 24771ceae9227a4e708ac631226f671ff58087cd Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 16:23:07 +0100 Subject: [PATCH 06/16] test(webapp): use _sk_ additional API key infix --- apps/webapp/test/environmentVariableApiAccess.test.ts | 4 ++-- apps/webapp/test/projectEnvironmentCredentialRoute.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/test/environmentVariableApiAccess.test.ts b/apps/webapp/test/environmentVariableApiAccess.test.ts index 2e4846d218..703df55895 100644 --- a/apps/webapp/test/environmentVariableApiAccess.test.ts +++ b/apps/webapp/test/environmentVariableApiAccess.test.ts @@ -22,12 +22,12 @@ describe("presentedApiKeyFromAuthentication", () => { type: "apiKey", result: { ok: true, - apiKey: "tr_prod_ak_presented", + apiKey: "tr_prod_sk_presented", type: "PRIVATE", environment: {} as never, }, }) - ).toBe("tr_prod_ak_presented"); + ).toBe("tr_prod_sk_presented"); }); it("does not exchange user tokens for an API key", () => { diff --git a/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts b/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts index 0374e49faa..62ca5c9b05 100644 --- a/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts +++ b/apps/webapp/test/projectEnvironmentCredentialRoute.test.ts @@ -69,7 +69,7 @@ describe("project environment credential response", () => { type: "apiKey", result: { ok: true, - apiKey: "tr_prod_ak_presented", + apiKey: "tr_prod_sk_presented", type: "PRIVATE", environment, }, @@ -80,7 +80,7 @@ describe("project environment credential response", () => { expect(response.status).toBe(200); await expect(responseJson(response)).resolves.toMatchObject({ - apiKey: "tr_prod_ak_presented", + apiKey: "tr_prod_sk_presented", projectId: "proj_123", }); }); From e148256d8b9d354ccfbc0f8d5d622c4f3d9c35ac Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 16:47:51 +0100 Subject: [PATCH 07/16] test(webapp): align auth expectations with scoped filters --- apps/webapp/test/auth-api.e2e.full.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts index 988a8f7071..5a077d2439 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -913,7 +913,7 @@ describe("API", () => { expect(res.status).toBe(403); }); - it("filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a → passes (array match)", async () => { + it("filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a → 403 (requires every task)", async () => { const server = getTestServer(); const seed = await seedTestEnvironment(server.prisma); const jwt = await generateJWT({ @@ -928,11 +928,9 @@ describe("API", () => { const res = await get("?filter%5BtaskIdentifier%5D=task_a%2Ctask_b", { Authorization: `Bearer ${jwt}`, }); - // Resource array is [{type:"runs"}, {type:"tasks",id:"task_a"}, {type:"tasks",id:"task_b"}]. - // The scope read:tasks:task_a matches the second element → access granted. - // Handler may 500 (ClickHouse unreachable in tests) but auth passed. - expect(res.status).not.toBe(401); - expect(res.status).not.toBe(403); + // A task-scoped JWT must authorize every requested task so including an + // unauthorized task in a multi-task filter cannot expose its runs. + expect(res.status).toBe(403); }); it("filter[taskIdentifier]=task_a + JWT read:tasks:task_z → 403 (no array match)", async () => { @@ -2473,13 +2471,14 @@ describe("API", () => { expect(res.status).not.toBe(403); }); - it("read:tasks (type-only) on no-filter list: 403 (filter is sessions, not tasks)", async () => { - // No filter → resource is `{ type: "sessions" }` only. read:tasks - // doesn't match the sessions type, so 403 — explicit narrowing. + it("read:tasks (type-only) on no-filter list: auth passes", async () => { + // Preserve the legacy behavior where a type-level task scope grants + // access to an unfiltered list while task ID scopes require a filter. const seed = await seedTestEnvironment(getTestServer().prisma); const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:tasks"]); const res = await fetchWithJwt(jwt); - expect(res.status).toBe(403); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); }); it("write:tasks:foo (wrong action) on filter=foo: 403", async () => { From 38952398e91326fdaa1f747675ac058542b3eaea Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 20:57:01 +0100 Subject: [PATCH 08/16] route additional api keys separately --- .../app/models/runtimeEnvironment.server.ts | 61 ++++---- .../test/findEnvironmentByApiKey.test.ts | 39 ++++- .../rbac/src/apiKeyPolicies.test.ts | 143 ++++++++++++++++-- .../rbac/src/bearerCredentials.ts | 56 +++---- internal-packages/rbac/src/index.ts | 31 ++-- packages/core/package.json | 12 ++ 6 files changed, 255 insertions(+), 87 deletions(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 9cfab3dbe5..96e207e8a9 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -6,6 +6,7 @@ import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver. import { logger } from "~/services/logger.server"; import { getUsername } from "~/utils/username"; import { hashApiKey } from "~/utils/apiKeys"; +import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; import { scopesGrantFullAccess } from "@trigger.dev/rbac"; @@ -125,16 +126,18 @@ async function resolveEnvironmentByApiKey( } satisfies Prisma.RuntimeEnvironmentInclude; const now = new Date(); - let additionalApiKey: { id: string; lastUsedAt: Date | null } | null = null; - let environment = await tx.runtimeEnvironment.findFirst({ - where: { - apiKey, - }, - include, - }); + const routesToAdditionalKey = isAdditionalApiKey(apiKey); + let rootEnvironment = routesToAdditionalKey + ? null + : await tx.runtimeEnvironment.findFirst({ + where: { + apiKey, + }, + include, + }); // Fall back to root keys that were rotated within the grace window. - if (!environment) { + if (!routesToAdditionalKey && !rootEnvironment) { const revokedApiKey = await tx.revokedApiKey.findFirst({ where: { apiKey, @@ -145,34 +148,34 @@ async function resolveEnvironmentByApiKey( }, }); - environment = revokedApiKey?.runtimeEnvironment ?? null; + rootEnvironment = revokedApiKey?.runtimeEnvironment ?? null; } // Additional keys are host-owned credentials. Legacy routes cannot apply a // scoped ability, so only an explicit full-access scope is accepted. - if (!environment) { - const match = await tx.apiKey.findFirst({ - where: { - keyHash: hashApiKey(apiKey), - revokedAt: null, - OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], - }, - select: { - id: true, - lastUsedAt: true, - scopes: true, - runtimeEnvironment: { include }, - }, - }); - - if (match && !scopesGrantFullAccess(match.scopes)) { - return { ok: false, reason: "restricted" }; - } + const match = routesToAdditionalKey + ? await tx.apiKey.findFirst({ + where: { + keyHash: hashApiKey(apiKey), + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + select: { + id: true, + lastUsedAt: true, + scopes: true, + runtimeEnvironment: { include }, + }, + }) + : null; - additionalApiKey = match ? { id: match.id, lastUsedAt: match.lastUsedAt } : null; - environment = match?.runtimeEnvironment ?? null; + if (match && !scopesGrantFullAccess(match.scopes)) { + return { ok: false, reason: "restricted" }; } + const additionalApiKey = match ? { id: match.id, lastUsedAt: match.lastUsedAt } : null; + let environment = rootEnvironment ?? match?.runtimeEnvironment ?? null; + if (!environment) { return { ok: false, reason: "not-found" }; } diff --git a/apps/webapp/test/findEnvironmentByApiKey.test.ts b/apps/webapp/test/findEnvironmentByApiKey.test.ts index f88f3e9e2d..70fcf09b44 100644 --- a/apps/webapp/test/findEnvironmentByApiKey.test.ts +++ b/apps/webapp/test/findEnvironmentByApiKey.test.ts @@ -1,6 +1,6 @@ import { postgresTest } from "@internal/testcontainers"; import { type PrismaClient } from "@trigger.dev/database"; -import { describe, expect, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { findEnvironmentByApiKey } from "~/models/runtimeEnvironment.server"; import { generateAdditionalApiKey, hashApiKey } from "~/utils/apiKeys"; import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; @@ -162,6 +162,43 @@ describe("findEnvironmentByApiKey — non-branchable", () => { const resolved = await findEnvironmentByApiKey("tr_dev_nonexistent", undefined, prisma); expect(resolved).toBeNull(); }); + + it("queries only the additional-key store for a valid additional-key format", async () => { + const runtimeEnvironmentFind = vi.fn(); + const revokedApiKeyFind = vi.fn(); + const apiKeyFind = vi.fn(async () => null); + const tx = { + runtimeEnvironment: { findFirst: runtimeEnvironmentFind }, + revokedApiKey: { findFirst: revokedApiKeyFind }, + apiKey: { findFirst: apiKeyFind }, + } as unknown as PrismaClient; + + await expect( + findEnvironmentByApiKey("tr_prod_sk_0123456789abcdefghijklmn", undefined, tx) + ).resolves.toBeNull(); + expect(apiKeyFind).toHaveBeenCalledOnce(); + expect(runtimeEnvironmentFind).not.toHaveBeenCalled(); + expect(revokedApiKeyFind).not.toHaveBeenCalled(); + }); + + it.each(["tr_prod_ak_0123456789abcdefghijklmn", "tr_prod_sk_too-short"])( + "keeps malformed additional-key formats on the root lookup path: %s", + async (apiKey) => { + const runtimeEnvironmentFind = vi.fn(async () => null); + const revokedApiKeyFind = vi.fn(async () => null); + const apiKeyFind = vi.fn(); + const tx = { + runtimeEnvironment: { findFirst: runtimeEnvironmentFind }, + revokedApiKey: { findFirst: revokedApiKeyFind }, + apiKey: { findFirst: apiKeyFind }, + } as unknown as PrismaClient; + + await expect(findEnvironmentByApiKey(apiKey, undefined, tx)).resolves.toBeNull(); + expect(runtimeEnvironmentFind).toHaveBeenCalledOnce(); + expect(revokedApiKeyFind).toHaveBeenCalledOnce(); + expect(apiKeyFind).not.toHaveBeenCalled(); + } + ); }); describe("findEnvironmentByApiKey — additional and disabled keys", () => { diff --git a/internal-packages/rbac/src/apiKeyPolicies.test.ts b/internal-packages/rbac/src/apiKeyPolicies.test.ts index 4344f9c63b..0a592684b0 100644 --- a/internal-packages/rbac/src/apiKeyPolicies.test.ts +++ b/internal-packages/rbac/src/apiKeyPolicies.test.ts @@ -19,6 +19,8 @@ type LazyControllerInternals = { }; const prismaPlaceholder = {} as PrismaClient; +const ADDITIONAL_API_KEY = "tr_prod_sk_0123456789abcdefghijklmn"; +const ROOT_API_KEY = "tr_prod_0123456789abcdefghijklmn"; const environment = { id: "env_123", organizationId: "org_123", @@ -53,6 +55,20 @@ function additionalKeyResult(scopes: string[]): AuthSuccess { }; } +function rootKeyResult(): AuthSuccess { + return { + ok: true, + environment, + subject: { + type: "user", + userId: "user_123", + organizationId: environment.organizationId, + projectId: environment.projectId, + }, + ability: buildJwtAbility(["admin"]), + }; +} + function publicJwtResult(): AuthSuccess { return { ok: true, @@ -71,6 +87,12 @@ function publicJwt(payload: Record) { return `header.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.signature`; } +function bearerRequest(token: string) { + return new Request("https://api.trigger.dev/test", { + headers: { Authorization: `Bearer ${token}` }, + }); +} + describe("API-key policy controller composition", () => { it("routes public JWTs directly to the host without calling the plugin authenticator", async () => { const pluginAuthenticate = vi.fn(); @@ -94,28 +116,20 @@ describe("API-key policy controller composition", () => { expect(pluginAuthenticate).not.toHaveBeenCalled(); }); - it("uses the scoped host result unchanged when plugin auth falls back to an additional key", async () => { - const pluginAuthenticate = vi.fn(async () => ({ - ok: false as const, - status: 401 as const, - error: "Invalid API key", - })); + it("routes valid additional keys directly to the host and preserves their scopes", async () => { + const pluginAuthenticate = vi.fn(); + const hostAuthenticate = vi.fn(async () => additionalKeyResult(["write:tasks:send-email"])); const plugin = { isUsingPlugin: vi.fn(async () => true), authenticateBearer: pluginAuthenticate, } as unknown as RoleBaseAccessController; - const controller = installPlugin( - plugin, - vi.fn(async () => additionalKeyResult(["write:tasks:send-email"])) - ); + const controller = installPlugin(plugin, hostAuthenticate); - const result = await controller.authenticateBearer( - new Request("https://api.trigger.dev/test", { - headers: { Authorization: "Bearer tr_additional" }, - }) - ); + const result = await controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY)); expect(result.ok).toBe(true); + expect(hostAuthenticate).toHaveBeenCalledOnce(); + expect(pluginAuthenticate).not.toHaveBeenCalled(); if (!result.ok) return; expect(result.subject).toMatchObject({ type: "apiKey", restricted: true }); expect(result.ability.can("trigger", { type: "tasks", id: "send-email" })).toBe(true); @@ -123,6 +137,105 @@ describe("API-key policy controller composition", () => { expect(result.ability.can("read", { type: "runs" })).toBe(false); }); + it("returns an unknown additional key's host 401 without calling the plugin", async () => { + const hostFailure = { + ok: false as const, + status: 401 as const, + error: "Invalid API key", + }; + const pluginAuthenticate = vi.fn(); + const hostAuthenticate = vi.fn(async () => hostFailure); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + + await expect(controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY))).resolves.toEqual( + hostFailure + ); + expect(hostAuthenticate).toHaveBeenCalledOnce(); + expect(pluginAuthenticate).not.toHaveBeenCalled(); + }); + + it("routes current root keys to the plugin without calling the host", async () => { + const pluginAuthenticate = vi.fn(async () => rootKeyResult()); + const hostAuthenticate = vi.fn(); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + + await expect(controller.authenticateBearer(bearerRequest(ROOT_API_KEY))).resolves.toMatchObject( + { + ok: true, + subject: { type: "user" }, + } + ); + expect(pluginAuthenticate).toHaveBeenCalledOnce(); + expect(hostAuthenticate).not.toHaveBeenCalled(); + }); + + it.each([401, 403] as const)( + "returns a plugin %s for root-shaped keys without calling the host", + async (status) => { + const pluginFailure = { + ok: false as const, + status, + error: "Unauthorized", + }; + const pluginAuthenticate = vi.fn(async () => pluginFailure); + const hostAuthenticate = vi.fn(); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + + await expect(controller.authenticateBearer(bearerRequest(ROOT_API_KEY))).resolves.toEqual( + pluginFailure + ); + expect(pluginAuthenticate).toHaveBeenCalledOnce(); + expect(hostAuthenticate).not.toHaveBeenCalled(); + } + ); + + it("fails closed when an additional-key host route resolves a non-apiKey subject", async () => { + const pluginAuthenticate = vi.fn(); + const hostAuthenticate = vi.fn(async () => rootKeyResult()); + const plugin = { + isUsingPlugin: vi.fn(async () => true), + authenticateBearer: pluginAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(plugin, hostAuthenticate); + + await expect(controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY))).resolves.toEqual( + { + ok: false, + status: 401, + error: "Invalid API key", + } + ); + expect(pluginAuthenticate).not.toHaveBeenCalled(); + }); + + it("keeps additional-key authentication in the no-plugin fallback controller", async () => { + const fallbackAuthenticate = vi.fn(async () => additionalKeyResult(["admin"])); + const hostAuthenticate = vi.fn(); + const fallback = { + isUsingPlugin: vi.fn(async () => false), + authenticateBearer: fallbackAuthenticate, + } as unknown as RoleBaseAccessController; + const controller = installPlugin(fallback, hostAuthenticate); + + await expect( + controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY)) + ).resolves.toMatchObject({ ok: true, subject: { type: "apiKey" } }); + expect(fallbackAuthenticate).toHaveBeenCalledOnce(); + expect(hostAuthenticate).not.toHaveBeenCalled(); + }); + it("delegates API-key policy catalogue, preparation, and description", async () => { const presets = vi.fn(async () => []); const prepare = vi.fn(async () => ({ diff --git a/internal-packages/rbac/src/bearerCredentials.ts b/internal-packages/rbac/src/bearerCredentials.ts index fb2d6b7e5c..8895653dd4 100644 --- a/internal-packages/rbac/src/bearerCredentials.ts +++ b/internal-packages/rbac/src/bearerCredentials.ts @@ -1,6 +1,7 @@ import type { BearerAuthResult, RbacEnvironment, RbacSubject } from "@trigger.dev/plugins"; import { createHash } from "node:crypto"; import type { PrismaClient } from "@trigger.dev/database"; +import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; import { extractJWTSub, isPublicJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; import { scopesGrantFullAccess } from "@trigger.dev/plugins"; @@ -163,16 +164,18 @@ export class BearerCredentialResolver { childEnvironments: branchName ? { where: { branchName, archivedAt: null } } : undefined, } as const; const now = new Date(); - let additionalApiKey: { id: string; lastUsedAt: Date | null; scopes: string[] } | null = null; - let env = await this.replica.runtimeEnvironment.findFirst({ - where: { apiKey: rawToken }, - include, - }); + const routesToAdditionalKey = isAdditionalApiKey(rawToken); + let rootEnvironment = routesToAdditionalKey + ? null + : await this.replica.runtimeEnvironment.findFirst({ + where: { apiKey: rawToken }, + include, + }); // Revoked API key grace window — recently rotated keys keep working until // their `expiresAt`; without this a customer who rotates an env API key // gets immediate 401s on the new auth path. - if (!env) { + if (!routesToAdditionalKey && !rootEnvironment) { const revoked = await this.replica.revokedApiKey.findFirst({ where: { apiKey: rawToken, @@ -180,29 +183,28 @@ export class BearerCredentialResolver { }, include: { runtimeEnvironment: { include } }, }); - env = revoked?.runtimeEnvironment ?? null; + rootEnvironment = revoked?.runtimeEnvironment ?? null; } - if (!env) { - const match = await this.replica.apiKey.findFirst({ - where: { - keyHash: createHash("sha256").update(rawToken, "utf8").digest("hex"), - revokedAt: null, - OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], - }, - select: { - id: true, - lastUsedAt: true, - scopes: true, - runtimeEnvironment: { include }, - }, - }); - - additionalApiKey = match - ? { id: match.id, lastUsedAt: match.lastUsedAt, scopes: match.scopes } - : null; - env = match?.runtimeEnvironment ?? null; - } + const match = routesToAdditionalKey + ? await this.replica.apiKey.findFirst({ + where: { + keyHash: createHash("sha256").update(rawToken, "utf8").digest("hex"), + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + select: { + id: true, + lastUsedAt: true, + scopes: true, + runtimeEnvironment: { include }, + }, + }) + : null; + const additionalApiKey = match + ? { id: match.id, lastUsedAt: match.lastUsedAt, scopes: match.scopes } + : null; + let env = rootEnvironment ?? match?.runtimeEnvironment ?? null; if (!env || env.project.deletedAt !== null) { return { ok: false, status: 401, error: "Invalid API key" }; diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index e55b5b682e..0170a3eb6b 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -13,6 +13,7 @@ import type { RoleMutationResult, } from "@trigger.dev/plugins"; import type { PrismaClient } from "@trigger.dev/database"; +import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; import { isPublicJWT } from "@trigger.dev/core/v3/jwt"; import { RoleBaseAccessFallback } from "./fallback.js"; @@ -195,22 +196,22 @@ class LazyController implements RoleBaseAccessController { ?.replace(/^Bearer /, "") .trim(); const useHostForPublicJWT = Boolean(options?.allowJWT && rawToken && isPublicJWT(rawToken)); + const useHostForAdditionalKey = Boolean( + !useHostForPublicJWT && usingPlugin && rawToken && isAdditionalApiKey(rawToken) + ); - // Public JWT validation is host-owned. Route it directly to the host so - // plugin implementations cannot drift from the canonical JWT checks. - let result = useHostForPublicJWT - ? await this._hostCredentialResolver.authenticate(...args) - : await controller.authenticateBearer(...args); - - // Additional environment API keys are stored by the host, not by the - // optional RBAC plugin. If the plugin does not recognize a bearer token, - // let the host resolver try that principal type. Root keys and all - // plugin-owned principals remain authoritative in the plugin. - if (!useHostForPublicJWT && !result.ok && result.status === 401 && usingPlugin) { - const hostResult = await this._hostCredentialResolver.authenticate(...args); - if (hostResult.ok && hostResult.subject.type === "apiKey") { - result = hostResult; - } + // Public JWT validation and additional environment API keys are host-owned. + // Route those formats directly to the host; all other bearer credentials + // remain authoritative in the installed controller. + const result = + useHostForPublicJWT || useHostForAdditionalKey + ? await this._hostCredentialResolver.authenticate(...args) + : await controller.authenticateBearer(...args); + + // The format is only a routing hint. A successful host resolution on the + // additional-key path must still produce the expected principal type. + if (useHostForAdditionalKey && result.ok && result.subject.type !== "apiKey") { + return { ok: false as const, status: 401 as const, error: "Invalid API key" }; } return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; diff --git a/packages/core/package.json b/packages/core/package.json index f0c3adfcf2..0c0bbd9271 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,6 +31,7 @@ "./v3/build": "./src/v3/build/index.ts", "./v3/apps": "./src/v3/apps/index.ts", "./v3/auth/environment": "./src/v3/auth/environment.ts", + "./v3/apiKeys": "./src/v3/apiKeys.ts", "./v3/jwt": "./src/v3/jwt.ts", "./v3/errors": "./src/v3/errors.ts", "./v3/logger-api": "./src/v3/logger-api.ts", @@ -358,6 +359,17 @@ "default": "./dist/commonjs/v3/auth/environment.js" } }, + "./v3/apiKeys": { + "import": { + "@triggerdotdev/source": "./src/v3/apiKeys.ts", + "types": "./dist/esm/v3/apiKeys.d.ts", + "default": "./dist/esm/v3/apiKeys.js" + }, + "require": { + "types": "./dist/commonjs/v3/apiKeys.d.ts", + "default": "./dist/commonjs/v3/apiKeys.js" + } + }, "./v3/jwt": { "import": { "@triggerdotdev/source": "./src/v3/jwt.ts", From e4244993dd3da5cbeb944d0549339dd3bf25b1f4 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 28 Jul 2026 10:18:41 +0100 Subject: [PATCH 09/16] feat(webapp): add additional-key rollout flag and auth observability Add a global, org-locked kill switch (additionalApiKeyLookupEnabled, default off) gating the additional environment API-key lookup, and bounded OTel auth telemetry (api_auth.attempts counter, api_auth.duration_ms histogram, api_auth.rollout_mode gauge) to watch the rollout. Attributes are closed enums only (resolver, credential_kind, result, lookup_path); no credentials, hashes, or tenant identifiers are recorded. --- .../app/models/runtimeEnvironment.server.ts | 25 ++- apps/webapp/app/services/apiAuth.server.ts | 28 ++-- .../services/authFeatureControls.server.ts | 13 ++ .../app/services/authFeatureControls.ts | 13 ++ .../app/services/authTelemetry.server.ts | 145 ++++++++++++++++++ apps/webapp/app/services/rbac.server.ts | 2 + .../routeBuilders/apiBuilder.server.ts | 3 +- apps/webapp/app/v3/featureFlags.ts | 9 ++ apps/webapp/test/authFeatureControls.test.ts | 19 +++ .../test/findEnvironmentByApiKey.test.ts | 44 ++++-- apps/webapp/test/rbacFallbackBranch.test.ts | 33 +++- .../rbac/src/apiKeyPolicies.test.ts | 26 ++-- .../rbac/src/bearerCredentials.ts | 117 +++++++++++++- internal-packages/rbac/src/fallback.ts | 3 +- internal-packages/rbac/src/index.ts | 51 +++++- 15 files changed, 471 insertions(+), 60 deletions(-) create mode 100644 apps/webapp/app/services/authFeatureControls.server.ts create mode 100644 apps/webapp/app/services/authFeatureControls.ts create mode 100644 apps/webapp/app/services/authTelemetry.server.ts create mode 100644 apps/webapp/test/authFeatureControls.test.ts diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 96e207e8a9..84d2979bb1 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -9,6 +9,7 @@ import { hashApiKey } from "~/utils/apiKeys"; import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; import { scopesGrantFullAccess } from "@trigger.dev/rbac"; +import { authFeatureControls } from "~/services/authFeatureControls.server"; export type { RuntimeEnvironment }; @@ -98,7 +99,7 @@ export function toAuthenticated( export type ApiKeyEnvironmentResolution = | { ok: true; environment: AuthenticatedEnvironment } - | { ok: false; reason: "not-found" | "restricted" }; + | { ok: false; reason: "not-found" | "restricted" | "disabled" }; /** * Resolve an environment from a raw API key for legacy routes that do not @@ -109,7 +110,8 @@ export type ApiKeyEnvironmentResolution = async function resolveEnvironmentByApiKey( apiKey: string, branchName: string | undefined, - tx: PrismaClientOrTransaction + tx: PrismaClientOrTransaction, + additionalApiKeyLookupEnabled: () => boolean ): Promise { const branch = sanitizeBranchName(branchName) ?? undefined; @@ -127,6 +129,10 @@ async function resolveEnvironmentByApiKey( const now = new Date(); const routesToAdditionalKey = isAdditionalApiKey(apiKey); + if (routesToAdditionalKey && !additionalApiKeyLookupEnabled()) { + return { ok: false, reason: "disabled" }; + } + let rootEnvironment = routesToAdditionalKey ? null : await tx.runtimeEnvironment.findFirst({ @@ -269,9 +275,15 @@ async function resolveEnvironmentByApiKey( export async function findEnvironmentByApiKey( apiKey: string, branchName: string | undefined, - tx: PrismaClientOrTransaction = $replica + tx: PrismaClientOrTransaction = $replica, + additionalApiKeyLookupEnabled = authFeatureControls.additionalApiKeyLookupEnabled ): Promise { - const resolution = await resolveEnvironmentByApiKey(apiKey, branchName, tx); + const resolution = await resolveEnvironmentByApiKey( + apiKey, + branchName, + tx, + additionalApiKeyLookupEnabled + ); return resolution.ok ? resolution.environment : null; } @@ -283,9 +295,10 @@ export async function findEnvironmentByApiKey( export async function findEnvironmentByApiKeyWithResolution( apiKey: string, branchName: string | undefined, - tx: PrismaClientOrTransaction = $replica + tx: PrismaClientOrTransaction = $replica, + additionalApiKeyLookupEnabled = authFeatureControls.additionalApiKeyLookupEnabled ): Promise { - return resolveEnvironmentByApiKey(apiKey, branchName, tx); + return resolveEnvironmentByApiKey(apiKey, branchName, tx, additionalApiKeyLookupEnabled); } /** diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 5a8c1e786a..a636f28867 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -30,7 +30,10 @@ import { } from "./organizationAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; -import { rbac } from "./rbac.server"; +import { + authenticateBearerWithTelemetry, + observeLegacyBearerAuthentication, +} from "~/services/authTelemetry.server"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), @@ -92,9 +95,9 @@ export async function authenticateApiRequest( return; } - const authentication = await authenticateApiKey(apiKey, { ...options, branchName }); - - return authentication; + return observeLegacyBearerAuthentication(request, () => + authenticateApiKey(apiKey, { ...options, branchName }) + ); } /** @@ -114,9 +117,9 @@ export async function authenticateApiRequestWithFailure( }; } - const authentication = await authenticateApiKeyWithFailure(apiKey, { ...options, branchName }); - - return authentication; + return observeLegacyBearerAuthentication(request, () => + authenticateApiKeyWithFailure(apiKey, { ...options, branchName }) + ); } /** @@ -306,13 +309,10 @@ export async function authenticateApiKeyWithScope( return { ok: false, status: 401, error: "Invalid or Missing API key" }; } - const result = await rbac.authenticateAuthorizeBearer( - request, - { action, resource }, - { allowJWT } - ); - if (!result.ok) { - return result; + const result = await authenticateBearerWithTelemetry(request, { allowJWT }); + if (!result.ok) return result; + if (!result.ability.can(action, resource)) { + return { ok: false, status: 403, error: "Unauthorized" }; } return { diff --git a/apps/webapp/app/services/authFeatureControls.server.ts b/apps/webapp/app/services/authFeatureControls.server.ts new file mode 100644 index 0000000000..5052b6b5a3 --- /dev/null +++ b/apps/webapp/app/services/authFeatureControls.server.ts @@ -0,0 +1,13 @@ +import { resolveAuthFeatureControls } from "~/services/authFeatureControls"; +import { globalFlagsRegistry } from "~/v3/globalFlagsRegistry.server"; + +export { resolveAuthFeatureControls } from "~/services/authFeatureControls"; +export type { AuthFeatureControls } from "~/services/authFeatureControls"; + +function currentControls() { + return resolveAuthFeatureControls(globalFlagsRegistry.current()); +} + +export const authFeatureControls = { + additionalApiKeyLookupEnabled: () => currentControls().additionalApiKeyLookupEnabled, +}; diff --git a/apps/webapp/app/services/authFeatureControls.ts b/apps/webapp/app/services/authFeatureControls.ts new file mode 100644 index 0000000000..1707dee37f --- /dev/null +++ b/apps/webapp/app/services/authFeatureControls.ts @@ -0,0 +1,13 @@ +import { FEATURE_FLAG, type FeatureFlagCatalog } from "~/v3/featureFlags"; + +export type AuthFeatureControls = { + additionalApiKeyLookupEnabled: boolean; +}; + +export function resolveAuthFeatureControls( + flags: Partial | Record | undefined +): AuthFeatureControls { + return { + additionalApiKeyLookupEnabled: flags?.[FEATURE_FLAG.additionalApiKeyLookupEnabled] === true, + }; +} diff --git a/apps/webapp/app/services/authTelemetry.server.ts b/apps/webapp/app/services/authTelemetry.server.ts new file mode 100644 index 0000000000..9da54c731e --- /dev/null +++ b/apps/webapp/app/services/authTelemetry.server.ts @@ -0,0 +1,145 @@ +import { getMeter } from "@internal/tracing"; +import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; +import { isPublicJWT } from "@trigger.dev/core/v3/jwt"; +import type { + BearerCredentialKind, + BearerLookupPath, + HostBearerAuthResult, +} from "@trigger.dev/rbac"; +import { authFeatureControls } from "~/services/authFeatureControls.server"; +import { rbac } from "~/services/rbac.server"; +import { singleton } from "~/utils/singleton"; + +export type ApiAuthResult = "success" | "invalid" | "forbidden" | "disabled" | "error"; + +const telemetry = singleton("apiAuthTelemetry", () => { + const meter = getMeter("api-auth"); + const attempts = meter.createCounter("api_auth.attempts", { + description: "Completed environment bearer authentication attempts", + }); + const duration = meter.createHistogram("api_auth.duration_ms", { + description: "Environment bearer authentication duration", + unit: "ms", + }); + + meter + .createObservableGauge("api_auth.rollout_mode", { + description: "Active API authentication rollout modes", + }) + .addCallback((result) => { + result.observe(1, { + control: "additional_key_lookup", + mode: authFeatureControls.additionalApiKeyLookupEnabled() ? "enabled" : "disabled", + }); + }); + + return { attempts, duration }; +}); + +export async function authenticateBearerWithTelemetry( + request: Request, + options: { allowJWT: boolean } +): Promise { + const startedAt = performance.now(); + const classified = classifyCredential(request, options.allowJWT); + let final = { ...classified, result: "error" as ApiAuthResult }; + + try { + const result = await rbac.authenticateBearer(request, options); + final = { + credentialKind: result.resolution.credentialKind, + lookupPath: result.resolution.lookupPath, + result: result.ok + ? "success" + : result.resolution.lookupPath === "additional_skipped" + ? "disabled" + : result.status === 403 + ? "forbidden" + : "invalid", + }; + recordAuthAttempt("rbac", final.credentialKind, final.lookupPath, final.result); + return result; + } catch (error) { + recordAuthAttempt("rbac", final.credentialKind, final.lookupPath, final.result); + throw error; + } finally { + telemetry.duration.record(performance.now() - startedAt, { + resolver: "rbac", + credential_kind: final.credentialKind, + result: final.result, + lookup_path: final.lookupPath, + }); + } +} + +export async function observeLegacyBearerAuthentication( + request: Request, + operation: () => Promise +): Promise { + const startedAt = performance.now(); + const classified = classifyCredential(request, true); + const lookupPath: BearerLookupPath = + classified.credentialKind === "additional_api_key" && + !authFeatureControls.additionalApiKeyLookupEnabled() + ? "additional_skipped" + : classified.lookupPath; + let result: ApiAuthResult = "error"; + + try { + const value = await operation(); + result = value?.ok ? "success" : lookupPath === "additional_skipped" ? "disabled" : "invalid"; + recordAuthAttempt("legacy", classified.credentialKind, lookupPath, result); + return value; + } catch (error) { + recordAuthAttempt("legacy", classified.credentialKind, lookupPath, result); + throw error; + } finally { + telemetry.duration.record(performance.now() - startedAt, { + resolver: "legacy", + credential_kind: classified.credentialKind, + result, + lookup_path: lookupPath, + }); + } +} + +function recordAuthAttempt( + resolver: "rbac" | "legacy", + credentialKind: BearerCredentialKind, + lookupPath: BearerLookupPath, + result: ApiAuthResult +) { + telemetry.attempts.add(1, { + resolver, + credential_kind: credentialKind, + result, + lookup_path: lookupPath, + }); +} + +// Best-effort pre-classification from the raw token format. This is only used +// for the metric attributes when the resolver throws before returning a +// resolution; the resolver's own resolution is authoritative on success/failure. +// Never records the credential itself — only its bounded format class. +function classifyCredential( + request: Request, + allowJWT: boolean +): { credentialKind: BearerCredentialKind; lookupPath: BearerLookupPath } { + const token = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + if (!token) return { credentialKind: "unknown", lookupPath: "not_found" }; + if (token.startsWith("pk_")) { + return { credentialKind: "legacy_public_key", lookupPath: "legacy_public" }; + } + if (allowJWT && isPublicJWT(token)) { + return { credentialKind: "public_jwt", lookupPath: "jwt_current" }; + } + if (isAdditionalApiKey(token)) { + return { credentialKind: "additional_api_key", lookupPath: "additional" }; + } + return token.startsWith("tr_") + ? { credentialKind: "root_api_key", lookupPath: "root_current" } + : { credentialKind: "unknown", lookupPath: "not_found" }; +} diff --git a/apps/webapp/app/services/rbac.server.ts b/apps/webapp/app/services/rbac.server.ts index 11510c1358..fc1cfd58cf 100644 --- a/apps/webapp/app/services/rbac.server.ts +++ b/apps/webapp/app/services/rbac.server.ts @@ -2,6 +2,7 @@ import { $replica, prisma } from "~/db.server"; import type { PrismaClient } from "@trigger.dev/database"; import plugin from "@trigger.dev/rbac"; import { env } from "~/env.server"; +import { authFeatureControls } from "~/services/authFeatureControls.server"; // plugin.create() is synchronous — returns a lazy controller that resolves // any installed RBAC plugin on first call. Top-level await is not used @@ -30,6 +31,7 @@ export const rbac = plugin.create( { forceFallback: env.RBAC_FORCE_FALLBACK, userActorSecret: env.SESSION_SECRET, + additionalApiKeyLookupEnabled: authFeatureControls.additionalApiKeyLookupEnabled, // A plugin that owns its own database client gets the same // writer/replica topology the webapp's Prisma clients use (see // getClient/getReplicaClient in db.server.ts): control-plane URLs win, diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index c4262db3e9..83212a6e8b 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -6,6 +6,7 @@ import { fromZodError } from "zod-validation-error"; import { apiCors } from "~/utils/apiCors"; import { logger } from "../logger.server"; import { rbac } from "../rbac.server"; +import { authenticateBearerWithTelemetry } from "~/services/authTelemetry.server"; import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; import { isUserActorToken } from "@trigger.dev/rbac"; import type { PersonalAccessTokenAuthenticationResult } from "../personalAccessToken.server"; @@ -60,7 +61,7 @@ async function authenticateRequestForApiBuilder( restrictedApiKey: boolean; } > { - const result = await rbac.authenticateBearer(request, { allowJWT }); + const result = await authenticateBearerWithTelemetry(request, { allowJWT }); if (!result.ok) { // Plugin auth distinguishes 401 (who are you?) from 403 (you're not // allowed) — e.g. a suspended account or IP block returns 403. diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 49a18eaf35..e90f25f7ea 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -22,6 +22,9 @@ export const FEATURE_FLAG = { // Grace-linger stamp carried alongside runOpsMintKind on flip. See mintFlipGrace.ts. runOpsMintKindPrev: "runOpsMintKindPrev", runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt", + // System-wide kill switch for additional (scoped) environment API-key lookup. + // Defaults off; enable during rollout once the new lookup path is trusted. + additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled", } as const; export const FeatureFlagCatalog = { @@ -61,6 +64,10 @@ export const FeatureFlagCatalog = { // by stampMintKindFlip on a genuine flip. Display-only (see ORG_LOCKED_FLAGS). [FEATURE_FLAG.runOpsMintKindPrev]: z.enum(["cuid", "runOpsId"]), [FEATURE_FLAG.runOpsMintKindFlippedAt]: z.string().datetime(), + // Strict z.boolean() (not z.coerce.boolean()): coercion turns the string + // "false" into true, which would silently enable this kill switch the wrong + // way if written as a string. Cold/absent resolves to the safe `false`. + [FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(), }; export type FeatureFlagKey = keyof typeof FeatureFlagCatalog; @@ -79,6 +86,8 @@ export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [ FEATURE_FLAG.taskEventRepository, FEATURE_FLAG.runOpsMintKindPrev, FEATURE_FLAG.runOpsMintKindFlippedAt, + // System-wide only — an org must not be able to override the rollout switch. + FEATURE_FLAG.additionalApiKeyLookupEnabled, ]; // Create a Zod schema from the existing catalog diff --git a/apps/webapp/test/authFeatureControls.test.ts b/apps/webapp/test/authFeatureControls.test.ts new file mode 100644 index 0000000000..5de10e348a --- /dev/null +++ b/apps/webapp/test/authFeatureControls.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { resolveAuthFeatureControls } from "~/services/authFeatureControls"; +import { FEATURE_FLAG, FeatureFlagCatalog, ORG_LOCKED_FLAGS } from "~/v3/featureFlags"; + +describe("auth feature controls", () => { + it("uses safe defaults for a cold or missing snapshot", () => { + expect(resolveAuthFeatureControls(undefined)).toEqual({ + additionalApiKeyLookupEnabled: false, + }); + }); + + it("accepts only strict booleans and locks org overrides", () => { + const flag = FEATURE_FLAG.additionalApiKeyLookupEnabled; + expect(FeatureFlagCatalog[flag].safeParse(true).success).toBe(true); + // Strict z.boolean(): the stringified "false" must not coerce to true. + expect(FeatureFlagCatalog[flag].safeParse("false").success).toBe(false); + expect(ORG_LOCKED_FLAGS).toContain(flag); + }); +}); diff --git a/apps/webapp/test/findEnvironmentByApiKey.test.ts b/apps/webapp/test/findEnvironmentByApiKey.test.ts index 70fcf09b44..c301a02b20 100644 --- a/apps/webapp/test/findEnvironmentByApiKey.test.ts +++ b/apps/webapp/test/findEnvironmentByApiKey.test.ts @@ -174,13 +174,31 @@ describe("findEnvironmentByApiKey — non-branchable", () => { } as unknown as PrismaClient; await expect( - findEnvironmentByApiKey("tr_prod_sk_0123456789abcdefghijklmn", undefined, tx) + findEnvironmentByApiKey("tr_prod_sk_0123456789abcdefghijklmn", undefined, tx, () => true) ).resolves.toBeNull(); expect(apiKeyFind).toHaveBeenCalledOnce(); expect(runtimeEnvironmentFind).not.toHaveBeenCalled(); expect(revokedApiKeyFind).not.toHaveBeenCalled(); }); + it("skips the additional-key store when lookup is disabled", async () => { + const runtimeEnvironmentFind = vi.fn(); + const revokedApiKeyFind = vi.fn(); + const apiKeyFind = vi.fn(); + const tx = { + runtimeEnvironment: { findFirst: runtimeEnvironmentFind }, + revokedApiKey: { findFirst: revokedApiKeyFind }, + apiKey: { findFirst: apiKeyFind }, + } as unknown as PrismaClient; + + await expect( + findEnvironmentByApiKey("tr_prod_sk_0123456789abcdefghijklmn", undefined, tx, () => false) + ).resolves.toBeNull(); + expect(apiKeyFind).not.toHaveBeenCalled(); + expect(runtimeEnvironmentFind).not.toHaveBeenCalled(); + expect(revokedApiKeyFind).not.toHaveBeenCalled(); + }); + it.each(["tr_prod_ak_0123456789abcdefghijklmn", "tr_prod_sk_too-short"])( "keeps malformed additional-key formats on the root lookup path: %s", async (apiKey) => { @@ -221,7 +239,7 @@ describe("findEnvironmentByApiKey — additional and disabled keys", () => { }, }); - const resolved = await findEnvironmentByApiKey(plaintext, undefined, prisma); + const resolved = await findEnvironmentByApiKey(plaintext, undefined, prisma, () => true); expect(resolved?.id).toBe(environment.id); expect(resolved?.apiKey).toBe(environment.apiKey); @@ -248,7 +266,9 @@ describe("findEnvironmentByApiKey — additional and disabled keys", () => { }, }); - await expect(findEnvironmentByApiKey(plaintext, undefined, prisma)).resolves.toBeNull(); + await expect( + findEnvironmentByApiKey(plaintext, undefined, prisma, () => true) + ).resolves.toBeNull(); } ); @@ -271,7 +291,9 @@ describe("findEnvironmentByApiKey — additional and disabled keys", () => { }, }); - await expect(findEnvironmentByApiKey(plaintext, undefined, prisma)).resolves.toBeNull(); + await expect( + findEnvironmentByApiKey(plaintext, undefined, prisma, () => true) + ).resolves.toBeNull(); }); postgresTest("rejects revoked and expired additional keys", async ({ prisma }) => { @@ -307,8 +329,12 @@ describe("findEnvironmentByApiKey — additional and disabled keys", () => { ], }); - await expect(findEnvironmentByApiKey(revoked, undefined, prisma)).resolves.toBeNull(); - await expect(findEnvironmentByApiKey(expired, undefined, prisma)).resolves.toBeNull(); + await expect( + findEnvironmentByApiKey(revoked, undefined, prisma, () => true) + ).resolves.toBeNull(); + await expect( + findEnvironmentByApiKey(expired, undefined, prisma, () => true) + ).resolves.toBeNull(); }); postgresTest( @@ -335,9 +361,9 @@ describe("findEnvironmentByApiKey — additional and disabled keys", () => { await expect( findEnvironmentByApiKey(environment.apiKey, undefined, prisma) ).resolves.toMatchObject({ id: environment.id }); - await expect(findEnvironmentByApiKey(additional, undefined, prisma)).resolves.toMatchObject({ - id: environment.id, - }); + await expect( + findEnvironmentByApiKey(additional, undefined, prisma, () => true) + ).resolves.toMatchObject({ id: environment.id }); } ); }); diff --git a/apps/webapp/test/rbacFallbackBranch.test.ts b/apps/webapp/test/rbacFallbackBranch.test.ts index b634b7cc94..cf5a9f0b77 100644 --- a/apps/webapp/test/rbacFallbackBranch.test.ts +++ b/apps/webapp/test/rbacFallbackBranch.test.ts @@ -3,7 +3,7 @@ import plugin from "@trigger.dev/rbac"; import { createHash } from "node:crypto"; import { generateJWT } from "@trigger.dev/core/v3/jwt"; import { type PrismaClient } from "@trigger.dev/database"; -import { describe, expect, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { generateAdditionalApiKey } from "~/utils/apiKeys"; import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; @@ -14,8 +14,11 @@ vi.setConfig({ testTimeout: 60_000 }); // mirrors findEnvironmentByApiKey, but is a separate implementation, so it // needs its own coverage. forceFallback skips loading the closed-source plugin // and uses the in-repo fallback directly. -function makeController(prisma: PrismaClient) { - return plugin.create({ primary: prisma, replica: prisma }, { forceFallback: true }); +function makeController(prisma: PrismaClient, additionalApiKeyLookupEnabled?: () => boolean) { + return plugin.create( + { primary: prisma, replica: prisma }, + { forceFallback: true, additionalApiKeyLookupEnabled } + ); } function bearerRequest(apiKey: string, branch?: string) { @@ -147,6 +150,30 @@ describe("RBAC fallback — DEVELOPMENT branch pivot", () => { }); describe("RBAC fallback — additional keys", () => { + it("rejects a disabled additional-key lookup without querying", async () => { + const runtimeEnvironmentFind = vi.fn(); + const revokedApiKeyFind = vi.fn(); + const apiKeyFind = vi.fn(); + const prisma = { + runtimeEnvironment: { findFirst: runtimeEnvironmentFind }, + revokedApiKey: { findFirst: revokedApiKeyFind }, + apiKey: { findFirst: apiKeyFind }, + } as unknown as PrismaClient; + const rbac = makeController(prisma, () => false); + const key = "tr_prod_sk_0123456789abcdefghijklmn"; + + await expect(rbac.authenticateBearer(bearerRequest(key))).resolves.toMatchObject({ + ok: false, + resolution: { + credentialKind: "additional_api_key", + lookupPath: "additional_skipped", + }, + }); + expect(runtimeEnvironmentFind).not.toHaveBeenCalled(); + expect(revokedApiKeyFind).not.toHaveBeenCalled(); + expect(apiKeyFind).not.toHaveBeenCalled(); + }); + postgresTest("authenticates an additional key and records its use", async ({ prisma }) => { const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); const rbac = makeController(prisma); diff --git a/internal-packages/rbac/src/apiKeyPolicies.test.ts b/internal-packages/rbac/src/apiKeyPolicies.test.ts index 0a592684b0..015ba1252e 100644 --- a/internal-packages/rbac/src/apiKeyPolicies.test.ts +++ b/internal-packages/rbac/src/apiKeyPolicies.test.ts @@ -151,9 +151,9 @@ describe("API-key policy controller composition", () => { } as unknown as RoleBaseAccessController; const controller = installPlugin(plugin, hostAuthenticate); - await expect(controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY))).resolves.toEqual( - hostFailure - ); + await expect( + controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY)) + ).resolves.toMatchObject(hostFailure); expect(hostAuthenticate).toHaveBeenCalledOnce(); expect(pluginAuthenticate).not.toHaveBeenCalled(); }); @@ -193,9 +193,9 @@ describe("API-key policy controller composition", () => { } as unknown as RoleBaseAccessController; const controller = installPlugin(plugin, hostAuthenticate); - await expect(controller.authenticateBearer(bearerRequest(ROOT_API_KEY))).resolves.toEqual( - pluginFailure - ); + await expect( + controller.authenticateBearer(bearerRequest(ROOT_API_KEY)) + ).resolves.toMatchObject(pluginFailure); expect(pluginAuthenticate).toHaveBeenCalledOnce(); expect(hostAuthenticate).not.toHaveBeenCalled(); } @@ -210,13 +210,13 @@ describe("API-key policy controller composition", () => { } as unknown as RoleBaseAccessController; const controller = installPlugin(plugin, hostAuthenticate); - await expect(controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY))).resolves.toEqual( - { - ok: false, - status: 401, - error: "Invalid API key", - } - ); + await expect( + controller.authenticateBearer(bearerRequest(ADDITIONAL_API_KEY)) + ).resolves.toMatchObject({ + ok: false, + status: 401, + error: "Invalid API key", + }); expect(pluginAuthenticate).not.toHaveBeenCalled(); }); diff --git a/internal-packages/rbac/src/bearerCredentials.ts b/internal-packages/rbac/src/bearerCredentials.ts index 8895653dd4..64dcd82372 100644 --- a/internal-packages/rbac/src/bearerCredentials.ts +++ b/internal-packages/rbac/src/bearerCredentials.ts @@ -13,6 +13,33 @@ export type BearerCredentialClients = { replica: PrismaClient; }; +export type BearerCredentialKind = + | "root_api_key" + | "additional_api_key" + | "public_jwt" + | "legacy_public_key" + | "unknown"; + +export type BearerLookupPath = + | "plugin" + | "root_current" + | "root_rotated" + | "additional" + | "additional_skipped" + | "jwt_current" + | "jwt_rotated" + | "legacy_public" + | "not_found"; + +export type BearerResolution = { + credentialKind: BearerCredentialKind; + lookupPath: BearerLookupPath; +}; + +export type BearerCredentialResult = BearerAuthResult & { + resolution: BearerResolution; +}; + // JWT-signing material. Today this is the environment's root apiKey (or the // parent's, for branch envs) — which is why rotating a root key needs the // grace-window retry in `authenticate`. When a dedicated per-environment @@ -43,7 +70,10 @@ export class BearerCredentialResolver { private readonly prisma: PrismaClient; private readonly replica: PrismaClient; - constructor(clients: BearerCredentialClients) { + constructor( + clients: BearerCredentialClients, + private readonly additionalApiKeyLookupEnabled: () => boolean = () => true + ) { this.prisma = clients.primary; this.replica = clients.replica; } @@ -51,7 +81,7 @@ export class BearerCredentialResolver { async authenticate( request: Request, options?: { allowJWT?: boolean } - ): Promise { + ): Promise { // Deprecated public API keys (`pk_*` minted long before public JWTs // landed) are intentionally NOT handled here. That token format hasn't // been issued for years; any `pk_*` bearer on an apiBuilder route returns @@ -60,11 +90,25 @@ export class BearerCredentialResolver { .get("Authorization") ?.replace(/^Bearer /, "") .trim(); - if (!rawToken) return { ok: false, status: 401, error: "Invalid or Missing API key" }; + if (!rawToken) { + return { + ok: false, + status: 401, + error: "Invalid or Missing API key", + resolution: { credentialKind: "unknown", lookupPath: "not_found" }, + }; + } if (options?.allowJWT && isPublicJWT(rawToken)) { const envId = extractJWTSub(rawToken); - if (!envId) return { ok: false, status: 401, error: "Invalid Public Access Token" }; + if (!envId) { + return { + ok: false, + status: 401, + error: "Invalid Public Access Token", + resolution: { credentialKind: "public_jwt", lookupPath: "not_found" }, + }; + } // Match the include shape of the slim AuthenticatedEnvironment so // the bridge can use the returned env without a follow-up fetch. @@ -85,9 +129,15 @@ export class BearerCredentialResolver { }, }); if (!env || env.project.deletedAt !== null) { - return { ok: false, status: 401, error: "Invalid Public Access Token" }; + return { + ok: false, + status: 401, + error: "Invalid Public Access Token", + resolution: { credentialKind: "public_jwt", lookupPath: "not_found" }, + }; } + let lookupPath: BearerLookupPath = "jwt_current"; let result = await validateJWT(rawToken, resolveJwtSigningKey(env)); // Root-key rotation grace window, mirroring the bearer path below: a @@ -111,6 +161,8 @@ export class BearerCredentialResolver { select: { apiKey: true }, }); + if (revoked.length > 0) lookupPath = "jwt_rotated"; + for (const candidate of revoked) { const retried = await validateJWT(rawToken, candidate.apiKey); if (retried.ok) { @@ -120,7 +172,14 @@ export class BearerCredentialResolver { } } - if (!result.ok) return { ok: false, status: 401, error: "Public Access Token is invalid" }; + if (!result.ok) { + return { + ok: false, + status: 401, + error: "Public Access Token is invalid", + resolution: { credentialKind: "public_jwt", lookupPath }, + }; + } const scopes = Array.isArray(result.payload.scopes) ? (result.payload.scopes as string[]) @@ -143,6 +202,7 @@ export class BearerCredentialResolver { }, ability: buildJwtAbility(scopes), jwt: { realtime, oneTimeUse, ...(actSub ? { act: { sub: actSub } } : {}) }, + resolution: { credentialKind: "public_jwt", lookupPath }, }; } @@ -165,6 +225,18 @@ export class BearerCredentialResolver { } as const; const now = new Date(); const routesToAdditionalKey = isAdditionalApiKey(rawToken); + if (routesToAdditionalKey && !this.additionalApiKeyLookupEnabled()) { + return { + ok: false, + status: 401, + error: "Invalid API key", + resolution: { + credentialKind: "additional_api_key", + lookupPath: "additional_skipped", + }, + }; + } + let rootEnvironment = routesToAdditionalKey ? null : await this.replica.runtimeEnvironment.findFirst({ @@ -175,6 +247,7 @@ export class BearerCredentialResolver { // Revoked API key grace window — recently rotated keys keep working until // their `expiresAt`; without this a customer who rotates an env API key // gets immediate 401s on the new auth path. + let resolvedRotatedRoot = false; if (!routesToAdditionalKey && !rootEnvironment) { const revoked = await this.replica.revokedApiKey.findFirst({ where: { @@ -184,6 +257,7 @@ export class BearerCredentialResolver { include: { runtimeEnvironment: { include } }, }); rootEnvironment = revoked?.runtimeEnvironment ?? null; + resolvedRotatedRoot = rootEnvironment !== null; } const match = routesToAdditionalKey @@ -207,7 +281,15 @@ export class BearerCredentialResolver { let env = rootEnvironment ?? match?.runtimeEnvironment ?? null; if (!env || env.project.deletedAt !== null) { - return { ok: false, status: 401, error: "Invalid API key" }; + return { + ok: false, + status: 401, + error: "Invalid API key", + resolution: { + credentialKind: routesToAdditionalKey ? "additional_api_key" : "root_api_key", + lookupPath: routesToAdditionalKey ? "additional" : "not_found", + }, + }; } if ( @@ -234,6 +316,7 @@ export class BearerCredentialResolver { ok: false, status: 401, error: "x-trigger-branch header required for preview env", + resolution: bearerResolution(additionalApiKey !== null, resolvedRotatedRoot), }; } @@ -245,7 +328,12 @@ export class BearerCredentialResolver { if (branchName !== null && !isDevAndDefault) { const child = env.childEnvironments?.[0]; if (!child) { - return { ok: false, status: 401, error: "No matching branch env" }; + return { + ok: false, + status: 401, + error: "No matching branch env", + resolution: bearerResolution(additionalApiKey !== null, resolvedRotatedRoot), + }; } // Pivot to the child env: child's id/type/branchName, parent's // apiKey/orgMember/organization/project. @@ -284,10 +372,23 @@ export class BearerCredentialResolver { environment: toAuthenticatedEnvironment(env), subject, ability: additionalApiKey ? buildJwtAbility(additionalApiKey.scopes) : permissiveAbility, + resolution: bearerResolution(additionalApiKey !== null, resolvedRotatedRoot), }; } } +function bearerResolution( + additionalApiKey: boolean, + resolvedRotatedRoot: boolean +): BearerResolution { + return additionalApiKey + ? { credentialKind: "additional_api_key", lookupPath: "additional" } + : { + credentialKind: "root_api_key", + lookupPath: resolvedRotatedRoot ? "root_rotated" : "root_current", + }; +} + // Coerce a Prisma RuntimeEnvironment payload (with project/organization/ // orgMember/parentEnvironment includes) into the slim AuthenticatedEnvironment // the auth contract carries. Explicit coercion keeps diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts index d1d22f3b09..9054c0a43e 100644 --- a/internal-packages/rbac/src/fallback.ts +++ b/internal-packages/rbac/src/fallback.ts @@ -46,6 +46,7 @@ function resolvePrismaClients(input: PrismaInput): FallbackPrismaClients { export type FallbackOptions = { // Platform secret for verifying delegated user-actor tokens (tr_uat_). userActorSecret?: string; + additionalApiKeyLookupEnabled?: () => boolean; }; export class RoleBaseAccessFallback { @@ -74,7 +75,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController { this.prisma = clients.primary; this.replica = clients.replica; this.userActorSecret = options?.userActorSecret; - this.bearer = new BearerCredentialResolver(clients); + this.bearer = new BearerCredentialResolver(clients, options?.additionalApiKeyLookupEnabled); } async isUsingPlugin(): Promise { diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index 0170a3eb6b..c09312363d 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -1,6 +1,7 @@ import type { ApiKeyPolicyDescription, ApiKeyPreset, + BearerAuthResult, Permission, PrepareApiKeyPolicyResult, RbacAbility, @@ -17,8 +18,13 @@ import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; import { isPublicJWT } from "@trigger.dev/core/v3/jwt"; import { RoleBaseAccessFallback } from "./fallback.js"; -import { BearerCredentialResolver } from "./bearerCredentials.js"; +import { BearerCredentialResolver, type BearerResolution } from "./bearerCredentials.js"; export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins"; +export type { + BearerCredentialKind, + BearerLookupPath, + BearerResolution, +} from "./bearerCredentials.js"; /** * The controller surface as the HOST sees it, after LazyController has filled in @@ -30,7 +36,14 @@ export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigg * method — so host consumers should depend on this type, not on the plugin * contract, and get a total surface without writing their own guards. */ -export type HostRbacController = Required; +export type HostBearerAuthResult = BearerAuthResult & { resolution: BearerResolution }; + +export type HostRbacController = Omit, "authenticateBearer"> & { + authenticateBearer( + request: Request, + options?: { allowJWT?: boolean } + ): Promise; +}; export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins"; export { buildJwtAbility, scopesWithinAbility } from "./ability.js"; export { FULL_ACCESS_PRESET_ID, scopesGrantFullAccess } from "@trigger.dev/plugins"; @@ -61,6 +74,9 @@ export type RbacCreateOptions = { // follows the host's writer/replica topology. The fallback ignores this — // it queries through the Prisma clients passed as `RbacPrismaInput`. database?: RbacDatabaseConfig; + // Synchronous host-owned rollout control. Defaults to enabled for non-webapp + // consumers; the webapp passes its cold-safe global flag reader. + additionalApiKeyLookupEnabled?: () => boolean; }; // Route actions that historically authorised via the legacy checkAuthorization's @@ -97,7 +113,8 @@ class LazyController implements RoleBaseAccessController { constructor(prisma: RbacPrismaInput, options?: RbacCreateOptions) { this._hostCredentialResolver = new BearerCredentialResolver( - "primary" in prisma ? prisma : { primary: prisma, replica: prisma } + "primary" in prisma ? prisma : { primary: prisma, replica: prisma }, + options?.additionalApiKeyLookupEnabled ); this._init = this.load(prisma, options); // load() runs eagerly but the result is awaited lazily on first method @@ -116,6 +133,7 @@ class LazyController implements RoleBaseAccessController { if (options?.forceFallback) { return new RoleBaseAccessFallback(prisma, { userActorSecret: options?.userActorSecret, + additionalApiKeyLookupEnabled: options?.additionalApiKeyLookupEnabled, }).create(); } const moduleName = "@triggerdotdev/plugins/rbac"; @@ -175,6 +193,7 @@ class LazyController implements RoleBaseAccessController { return new RoleBaseAccessFallback(prisma, { userActorSecret: options?.userActorSecret, + additionalApiKeyLookupEnabled: options?.additionalApiKeyLookupEnabled, }).create(); } } @@ -208,13 +227,35 @@ class LazyController implements RoleBaseAccessController { ? await this._hostCredentialResolver.authenticate(...args) : await controller.authenticateBearer(...args); + const resolution: BearerResolution = + "resolution" in result + ? (result.resolution as BearerResolution) + : useHostForAdditionalKey + ? { credentialKind: "additional_api_key", lookupPath: "additional" } + : useHostForPublicJWT + ? { credentialKind: "public_jwt", lookupPath: "jwt_current" } + : { + credentialKind: rawToken?.startsWith("tr_") ? "root_api_key" : "unknown", + lookupPath: usingPlugin ? "plugin" : "not_found", + }; + // The format is only a routing hint. A successful host resolution on the // additional-key path must still produce the expected principal type. if (useHostForAdditionalKey && result.ok && result.subject.type !== "apiKey") { - return { ok: false as const, status: 401 as const, error: "Invalid API key" }; + return { + ok: false as const, + status: 401 as const, + error: "Invalid API key", + resolution: { + credentialKind: "additional_api_key" as const, + lookupPath: "additional" as const, + }, + }; } - return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; + return result.ok + ? { ...result, ability: withActionAliases(result.ability), resolution } + : { ...result, resolution }; } async authenticateSession(...args: Parameters) { From 2022bed5e888719c557a5eed751080a13caf68cb Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 28 Jul 2026 11:03:05 +0100 Subject: [PATCH 10/16] fix(webapp): restore scope-auth path and harden auth telemetry Revert authenticateApiKeyWithScope to rbac.authenticateAuthorizeBearer (the kill switch still applies via the rbac controller), and make the auth telemetry tolerate a missing resolution from test doubles. Also register the v3/apiKeys subpath in core's typesVersions for node10. --- apps/webapp/app/services/apiAuth.server.ts | 17 +++++++++-------- .../webapp/app/services/authTelemetry.server.ts | 9 ++++++--- packages/core/package.json | 3 +++ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index a636f28867..f485a6bb70 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -30,10 +30,8 @@ import { } from "./organizationAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; -import { - authenticateBearerWithTelemetry, - observeLegacyBearerAuthentication, -} from "~/services/authTelemetry.server"; +import { rbac } from "./rbac.server"; +import { observeLegacyBearerAuthentication } from "~/services/authTelemetry.server"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), @@ -309,10 +307,13 @@ export async function authenticateApiKeyWithScope( return { ok: false, status: 401, error: "Invalid or Missing API key" }; } - const result = await authenticateBearerWithTelemetry(request, { allowJWT }); - if (!result.ok) return result; - if (!result.ability.can(action, resource)) { - return { ok: false, status: 403, error: "Unauthorized" }; + const result = await rbac.authenticateAuthorizeBearer( + request, + { action, resource }, + { allowJWT } + ); + if (!result.ok) { + return result; } return { diff --git a/apps/webapp/app/services/authTelemetry.server.ts b/apps/webapp/app/services/authTelemetry.server.ts index 9da54c731e..9a229c70f3 100644 --- a/apps/webapp/app/services/authTelemetry.server.ts +++ b/apps/webapp/app/services/authTelemetry.server.ts @@ -46,12 +46,15 @@ export async function authenticateBearerWithTelemetry( try { const result = await rbac.authenticateBearer(request, options); + // The host LazyController always attaches `resolution`; fall back to the + // format-based classification if a caller (e.g. a test double) omits it. + const resolution = result.resolution ?? classified; final = { - credentialKind: result.resolution.credentialKind, - lookupPath: result.resolution.lookupPath, + credentialKind: resolution.credentialKind, + lookupPath: resolution.lookupPath, result: result.ok ? "success" - : result.resolution.lookupPath === "additional_skipped" + : resolution.lookupPath === "additional_skipped" ? "disabled" : result.status === 403 ? "forbidden" diff --git a/packages/core/package.json b/packages/core/package.json index 0c0bbd9271..c0ebe06ff8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -152,6 +152,9 @@ "v3/jwt": [ "dist/commonjs/v3/jwt.d.ts" ], + "v3/apiKeys": [ + "dist/commonjs/v3/apiKeys.d.ts" + ], "v3/runEngineWorker": [ "dist/commonjs/v3/runEngineWorker/index.d.ts" ], From 3725c5623e2327cd98817fa8c01bbf3d167ff234 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 11:28:24 +0100 Subject: [PATCH 11/16] 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 0000000000..cd7a739e32 --- /dev/null +++ b/.server-changes/api-key-deploy-envvars-presets.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Self-hosted deployments can now create multiple full-access API keys for each environment. diff --git a/.server-changes/public-token-additional-api-keys.md b/.server-changes/public-token-additional-api-keys.md new file mode 100644 index 0000000000..79b7f7d4a1 --- /dev/null +++ b/.server-changes/public-token-additional-api-keys.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Additional environment API keys can now create scoped public access tokens. diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts index 1994741722..c2cc1577df 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 846d279032..e33d83e113 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 1d6b12bd2d..001d5d81d2 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 0000000000..08def65fd7 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.auth.public-tokens.ts @@ -0,0 +1,6 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { handlePublicTokenRequest } from "~/services/publicTokens.server"; + +export async function action({ request }: ActionFunctionArgs) { + return handlePublicTokenRequest(request); +} diff --git a/apps/webapp/app/services/publicTokens.server.ts b/apps/webapp/app/services/publicTokens.server.ts new file mode 100644 index 0000000000..b61ab4560f --- /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 0000000000..f754740744 --- /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 0000000000..d97750798c --- /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 0000000000..afb5b82d1f --- /dev/null +++ b/apps/webapp/test/publicTokensRoute.test.ts @@ -0,0 +1,269 @@ +import { postgresTest } from "@internal/testcontainers"; +import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; +import type { PrismaClient } from "@trigger.dev/database"; +import { buildJwtAbility } from "@trigger.dev/plugins"; +import rbacPlugin, { type RbacAbility, type RoleBaseAccessController } from "@trigger.dev/rbac"; +import { describe, expect, it } from "vitest"; +import { handlePublicTokenRequest } from "~/services/publicTokens.server"; +import { generateAdditionalApiKey, generateRootApiKey, hashApiKey } from "~/utils/apiKeys"; +import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; + +function request(body: unknown, accessToken = "tr_prod_test", expirationTime?: string | number) { + return new Request("https://api.trigger.dev/api/v1/auth/public-tokens", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify( + expirationTime === undefined ? body : { ...(body as object), expirationTime } + ), + }); +} + +const environment = { + id: "env_test", + apiKey: "tr_prod_root_signing_secret", + parentEnvironment: null, +}; + +const permissiveAbility: RbacAbility = { + can: () => true, + canSuper: () => false, +}; + +function controllerWithAbility( + ability: RbacAbility, + subject: "root" | "additional" = "additional" +) { + return { + async authenticateBearer() { + return { + ok: true as const, + environment, + subject: + subject === "root" + ? { + type: "user" as const, + userId: "user_test", + organizationId: "org_test", + } + : { + type: "apiKey" as const, + apiKeyId: "key_test", + restricted: ability !== permissiveAbility, + organizationId: "org_test", + }, + ability, + }; + }, + } as unknown as Pick; +} + +async function responseJson(response: Response) { + return response.json() as Promise>; +} + +describe("POST /api/v1/auth/public-tokens", () => { + it("lets root and unrestricted additional keys mint arbitrary scopes", async () => { + for (const controller of [ + controllerWithAbility(permissiveAbility, "root"), + controllerWithAbility(permissiveAbility, "additional"), + ]) { + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs", "custom:resources:value"] }), + controller + ); + expect(response.status).toBe(200); + + const { token } = await responseJson(response); + const validation = await validateJWT(token, environment.apiKey); + expect(validation.ok).toBe(true); + if (!validation.ok) continue; + expect(validation.payload).toMatchObject({ + sub: environment.id, + pub: true, + scopes: ["read:runs", "custom:resources:value"], + }); + } + }); + + it("allows restricted subsets and rejects excess scopes", async () => { + const controller = controllerWithAbility( + buildJwtAbility(["read:runs", "trigger:tasks:send-email"]) + ); + + const allowed = await handlePublicTokenRequest( + request({ scopes: ["read:runs:run_123", "trigger:tasks:send-email"] }), + controller + ); + expect(allowed.status).toBe(200); + + const denied = await handlePublicTokenRequest( + request({ scopes: ["read:runs", "write:runs", "trigger:tasks"] }), + controller + ); + expect(denied.status).toBe(403); + await expect(responseJson(denied)).resolves.toMatchObject({ + code: "scopes_exceed_key_access", + deniedScopes: ["write:runs", "trigger:tasks"], + }); + }); + + it("rejects a type-level scope when the key only has per-id access", async () => { + const response = await handlePublicTokenRequest( + request({ scopes: ["trigger:tasks"] }), + controllerWithAbility(buildJwtAbility(["trigger:tasks:send-email"])) + ); + + expect(response.status).toBe(403); + await expect(responseJson(response)).resolves.toMatchObject({ + deniedScopes: ["trigger:tasks"], + }); + }); + + it("rejects empty scopes", async () => { + const response = await handlePublicTokenRequest( + request({ scopes: [] }), + controllerWithAbility(permissiveAbility) + ); + + expect(response.status).toBe(400); + }); + + it("rejects expirations longer than 30 days", async () => { + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }, undefined, "31d"), + controllerWithAbility(permissiveAbility) + ); + + expect(response.status).toBe(400); + await expect(responseJson(response)).resolves.toMatchObject({ + error: "Expiration time cannot exceed 30 days", + }); + }); + + it.each(["-5m", "5m ago"])("rejects an expiration in the past (%s)", async (expirationTime) => { + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }, undefined, expirationTime), + controllerWithAbility(permissiveAbility) + ); + + expect(response.status).toBe(400); + await expect(responseJson(response)).resolves.toMatchObject({ + error: "Expiration time must be in the future", + }); + }); + + it("signs the exp it validated rather than re-parsing the input string", async () => { + const before = Math.floor(Date.now() / 1000); + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }, undefined, "10m"), + controllerWithAbility(permissiveAbility) + ); + + expect(response.status).toBe(200); + const { token } = (await responseJson(response)) as { token: string }; + const validation = await validateJWT(token, environment.apiKey); + + expect(validation.ok).toBe(true); + // A single parse governs both the cap check and the claim. + const exp = (validation as { payload: { exp: number } }).payload.exp; + expect(exp).toBeGreaterThanOrEqual(before + 600); + expect(exp).toBeLessThanOrEqual(before + 601); + }); + + it("does not allow a public JWT bearer to mint another token", async () => { + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { sub: environment.id, pub: true, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const controller = { + async authenticateBearer(_request: Request, options?: { allowJWT?: boolean }) { + return options?.allowJWT + ? ({ + ok: true, + environment, + subject: { + type: "publicJWT", + environmentId: environment.id, + organizationId: "org_test", + }, + ability: buildJwtAbility(["read:runs"]), + } as const) + : ({ ok: false, status: 401, error: "Invalid API key" } as const); + }, + } as unknown as Pick; + + const response = await handlePublicTokenRequest( + request({ scopes: ["read:runs"] }, jwt), + controller + ); + + expect(response.status).toBe(401); + }); +}); + +function makeController(prisma: PrismaClient) { + return rbacPlugin.create({ primary: prisma, replica: prisma }, { forceFallback: true }); +} + +postgresTest( + "minted tokens round-trip after the root key is rotated", + async ({ prisma }) => { + const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma); + const originalRootKey = generateRootApiKey("PRODUCTION").apiKey; + const rotatedSigningKey = generateRootApiKey("PRODUCTION").apiKey; + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug: uniqueId("env"), + apiKey: originalRootKey, + pkApiKey: uniqueId("pk"), + shortcode: uniqueId("sc"), + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + orgMemberId: orgMember.id, + }, + }); + const additionalKey = generateAdditionalApiKey("PRODUCTION").apiKey; + await prisma.apiKey.create({ + data: { + name: "Token minter", + keyHash: hashApiKey(additionalKey), + lastFour: additionalKey.slice(-4), + runtimeEnvironmentId: environment.id, + createdByUserId: user.id, + presetId: "READ_ONLY", + scopes: ["read:runs"], + }, + }); + await prisma.runtimeEnvironment.update({ + where: { id: environment.id }, + data: { apiKey: rotatedSigningKey }, + }); + + const controller = makeController(prisma); + const mintResponse = await handlePublicTokenRequest( + request({ scopes: ["read:runs"], oneTimeUse: true }, additionalKey), + controller + ); + expect(mintResponse.status).toBe(200); + const { token } = await responseJson(mintResponse); + + const authResult = await controller.authenticateBearer( + new Request("https://api.trigger.dev/api/v1/runs", { + headers: { Authorization: `Bearer ${token}` }, + }), + { allowJWT: true } + ); + + expect(authResult.ok).toBe(true); + if (!authResult.ok) return; + expect(authResult.environment.id).toBe(environment.id); + expect(authResult.jwt?.oneTimeUse).toBe(true); + expect(authResult.ability.can("read", { type: "runs" })).toBe(true); + }, + 60_000 +); From 0151b741ec29f9eea270587a8c3fa4fa668b9b12 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 13:10:07 +0100 Subject: [PATCH 12/16] 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 c2cc1577df..c8e9ff2818 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 e33d83e113..ce562edfd0 100644 --- a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts @@ -1,15 +1,16 @@ import { type RuntimeEnvironment } from "@trigger.dev/database"; -import { scopesGrantFullAccess, type RoleBaseAccessController } from "@trigger.dev/rbac"; +// HostRbacController, not RoleBaseAccessController: the policy methods are +// optional on the plugin-facing contract, and `rbac` (LazyController) has +// already substituted the fail-closed defaults for any an installed plugin +// omits. Depending on the host surface keeps these call sites guard-free. +import { scopesGrantFullAccess, type HostRbacController } from "@trigger.dev/rbac"; import { type PrismaReplicaClient, $replica } from "~/db.server"; import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; import { rbac } from "~/services/rbac.server"; import { obfuscateApiKey } from "~/utils/apiKeys"; -type ApiKeyPolicyPresenter = Pick< - RoleBaseAccessController, - "apiKeyPresets" | "describeApiKeyPolicy" ->; +type ApiKeyPolicyPresenter = Pick; export class ApiKeysPresenter { // Read-only presenter for a dashboard page — all queries below are reads, so From 336d82419a6a9e3ebf7f7ede4f4c9c50245af37b Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 16:23:31 +0100 Subject: [PATCH 13/16] 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 f754740744..82ad8a5e4e 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 d97750798c..76f916eb19 100644 --- a/apps/webapp/test/createEnvironmentApiKey.test.ts +++ b/apps/webapp/test/createEnvironmentApiKey.test.ts @@ -45,7 +45,7 @@ containerTest("standalone fallback creates one explicit full-access key", async { prismaClient: prisma, rbacController: fallback } ); - expect(result.plaintext).toMatch(/^tr_prod_ak_[A-Za-z0-9]{24}$/); + expect(result.plaintext).toMatch(/^tr_prod_sk_[A-Za-z0-9]{24}$/); expect(result.apiKey).toMatchObject({ presetId: null, scopes: ["admin"], From d8c02825dcb341b649672aaaeea7c0b768e972ab Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 17:39:40 +0100 Subject: [PATCH 14/16] 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 e349bc086b..4bd070a5ba 100644 --- a/apps/webapp/app/consts.ts +++ b/apps/webapp/app/consts.ts @@ -10,6 +10,7 @@ export const RUN_CHUNK_EXECUTION_BUFFER = 350; export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504]; export const MAX_BATCH_TRIGGER_ITEMS = 100; +export const MAX_API_KEY_TASK_IDENTIFIERS = 10; export const MAX_TASK_RUN_ATTEMPTS = 250; export const BULK_ACTION_RUN_LIMIT = 250; export const MAX_JOB_RUN_EXECUTION_COUNT = 250; diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts index c8e9ff2818..af1f230269 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 001d5d81d2..3e6a6886e2 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 76f916eb19..399c531576 100644 --- a/apps/webapp/test/createEnvironmentApiKey.test.ts +++ b/apps/webapp/test/createEnvironmentApiKey.test.ts @@ -2,7 +2,8 @@ import { containerTest } from "@internal/testcontainers"; import type { PrismaClient } from "@trigger.dev/database"; import rbacPlugin, { type RoleBaseAccessController } from "@trigger.dev/rbac"; import { expect, vi } from "vitest"; -import { createEnvironmentApiKey, MAX_API_KEY_TASK_IDENTIFIERS } from "~/models/api-key.server"; +import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts"; +import { createEnvironmentApiKey } from "~/models/api-key.server"; import { createRuntimeEnvironment, createTestOrgProjectWithMember, From ba81e5e6619983fb9aec57eae66ab8aa587d87a1 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 28 Jul 2026 12:18:34 +0100 Subject: [PATCH 15/16] 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 af1f230269..4f44d3372a 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 3e6a6886e2..f3e9e0cfe7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -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 0000000000..380a146875 --- /dev/null +++ b/apps/webapp/app/services/additionalApiKeyIssuance.ts @@ -0,0 +1,17 @@ +import { FEATURE_FLAG, type FeatureFlagCatalog } from "~/v3/featureFlags"; + +export function resolveAdditionalApiKeyIssuance( + globalFlags: Partial | Record | undefined, + organizationFlags: Record | undefined +): boolean { + if (globalFlags?.[FEATURE_FLAG.additionalApiKeyIssuanceEnabled] !== true) { + return false; + } + + const organizationOverride = organizationFlags?.[FEATURE_FLAG.additionalApiKeysEnabled]; + if (organizationOverride === true || organizationOverride === false) { + return organizationOverride; + } + + return globalFlags?.[FEATURE_FLAG.additionalApiKeysEnabled] === true; +} diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index e90f25f7ea..68fe7dfc41 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -22,6 +22,10 @@ export const FEATURE_FLAG = { // Grace-linger stamp carried alongside runOpsMintKind on flip. See mintFlipGrace.ts. runOpsMintKindPrev: "runOpsMintKindPrev", runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt", + // Per-organization rollout for creating additional environment API keys. + additionalApiKeysEnabled: "additionalApiKeysEnabled", + // System-wide kill switch for issuing additional environment API keys. + additionalApiKeyIssuanceEnabled: "additionalApiKeyIssuanceEnabled", // System-wide kill switch for additional (scoped) environment API-key lookup. // Defaults off; enable during rollout once the new lookup path is trusted. additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled", @@ -64,9 +68,10 @@ export const FeatureFlagCatalog = { // by stampMintKindFlip on a genuine flip. Display-only (see ORG_LOCKED_FLAGS). [FEATURE_FLAG.runOpsMintKindPrev]: z.enum(["cuid", "runOpsId"]), [FEATURE_FLAG.runOpsMintKindFlippedAt]: z.string().datetime(), - // Strict z.boolean() (not z.coerce.boolean()): coercion turns the string - // "false" into true, which would silently enable this kill switch the wrong - // way if written as a string. Cold/absent resolves to the safe `false`. + // Strict booleans prevent a stringified "false" from silently enabling API-key + // creation or lookup. Cold/absent values resolve to the safe `false`. + [FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(), + [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: z.boolean(), [FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(), }; @@ -86,7 +91,8 @@ export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [ FEATURE_FLAG.taskEventRepository, FEATURE_FLAG.runOpsMintKindPrev, FEATURE_FLAG.runOpsMintKindFlippedAt, - // System-wide only — an org must not be able to override the rollout switch. + // System-wide only — orgs must not be able to override these kill switches. + FEATURE_FLAG.additionalApiKeyIssuanceEnabled, FEATURE_FLAG.additionalApiKeyLookupEnabled, ]; diff --git a/apps/webapp/test/additionalApiKeyIssuance.test.ts b/apps/webapp/test/additionalApiKeyIssuance.test.ts new file mode 100644 index 0000000000..57c3ca0f83 --- /dev/null +++ b/apps/webapp/test/additionalApiKeyIssuance.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { resolveAdditionalApiKeyIssuance } from "~/services/additionalApiKeyIssuance"; +import { FEATURE_FLAG, FeatureFlagCatalog, ORG_LOCKED_FLAGS } from "~/v3/featureFlags"; + +describe("additional API key issuance controls", () => { + it("registers strict rollout and system-wide flags", () => { + expect( + FeatureFlagCatalog[FEATURE_FLAG.additionalApiKeysEnabled].safeParse("false").success + ).toBe(false); + expect( + FeatureFlagCatalog[FEATURE_FLAG.additionalApiKeyIssuanceEnabled].safeParse("false").success + ).toBe(false); + expect(ORG_LOCKED_FLAGS).not.toContain(FEATURE_FLAG.additionalApiKeysEnabled); + expect(ORG_LOCKED_FLAGS).toContain(FEATURE_FLAG.additionalApiKeyIssuanceEnabled); + }); + + it("defaults to disabled", () => { + expect(resolveAdditionalApiKeyIssuance(undefined, undefined)).toBe(false); + }); + + it("requires the system-wide issuance gate", () => { + expect( + resolveAdditionalApiKeyIssuance( + { [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: false }, + { [FEATURE_FLAG.additionalApiKeysEnabled]: true } + ) + ).toBe(false); + }); + + it("allows an organization override when issuance is enabled", () => { + expect( + resolveAdditionalApiKeyIssuance( + { [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true }, + { [FEATURE_FLAG.additionalApiKeysEnabled]: true } + ) + ).toBe(true); + }); + + it("uses the global rollout value when the organization has no override", () => { + expect( + resolveAdditionalApiKeyIssuance( + { + [FEATURE_FLAG.additionalApiKeysEnabled]: true, + [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true, + }, + undefined + ) + ).toBe(true); + }); + + it("allows an organization to opt out of a global rollout", () => { + expect( + resolveAdditionalApiKeyIssuance( + { + [FEATURE_FLAG.additionalApiKeysEnabled]: true, + [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true, + }, + { [FEATURE_FLAG.additionalApiKeysEnabled]: false } + ) + ).toBe(false); + }); +}); diff --git a/apps/webapp/test/createEnvironmentApiKey.test.ts b/apps/webapp/test/createEnvironmentApiKey.test.ts index 399c531576..a9487018c1 100644 --- a/apps/webapp/test/createEnvironmentApiKey.test.ts +++ b/apps/webapp/test/createEnvironmentApiKey.test.ts @@ -4,6 +4,7 @@ import rbacPlugin, { type RoleBaseAccessController } from "@trigger.dev/rbac"; import { expect, vi } from "vitest"; import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts"; import { createEnvironmentApiKey } from "~/models/api-key.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; import { createRuntimeEnvironment, createTestOrgProjectWithMember, @@ -20,15 +21,59 @@ function policyController( async function setup(prisma: PrismaClient) { const { organization, project, user } = await createTestOrgProjectWithMember(prisma); - const environment = await createRuntimeEnvironment(prisma, { - projectId: project.id, - organizationId: organization.id, - type: "PRODUCTION", - slug: uniqueId("prod"), - }); + const [environment] = await Promise.all([ + createRuntimeEnvironment(prisma, { + projectId: project.id, + organizationId: organization.id, + type: "PRODUCTION", + slug: uniqueId("prod"), + }), + prisma.organization.update({ + where: { id: organization.id }, + data: { featureFlags: { [FEATURE_FLAG.additionalApiKeysEnabled]: true } }, + }), + prisma.featureFlag.upsert({ + where: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled }, + create: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled, value: true }, + update: { value: true }, + }), + ]); return { organization, project, user, environment }; } +containerTest( + "rejects creation when the system-wide issuance gate is disabled", + async ({ prisma }) => { + const { user, environment } = await setup(prisma); + await prisma.featureFlag.update({ + where: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled }, + data: { value: false }, + }); + const controller = policyController(async () => ({ + ok: true, + policy: { presetId: null, scopes: ["admin"] }, + })); + + await expect( + createEnvironmentApiKey( + { + environmentId: environment.id, + taskEnvironmentId: environment.id, + userId: user.id, + name: "Disabled", + presetId: "FULL_ACCESS", + }, + { prismaClient: prisma, rbacController: controller } + ) + ).rejects.toThrow("Creating additional API keys is not enabled"); + + expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled(); + await expect( + prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } }) + ).resolves.toBe(0); + } +); + containerTest("standalone fallback creates one explicit full-access key", async ({ prisma }) => { const { user, environment } = await setup(prisma); const fallback = rbacPlugin.create({ primary: prisma, replica: prisma }, { forceFallback: true }); From ca744a5c8483cea298d6e079ace08bc4182813e8 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 28 Jul 2026 12:32:15 +0100 Subject: [PATCH 16/16] 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 4f44d3372a..c21df2fe65 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 0000000000..3d4b80fe5a --- /dev/null +++ b/apps/webapp/app/services/apiKeyTelemetry.server.ts @@ -0,0 +1,49 @@ +import { getMeter } from "@internal/tracing"; +import { singleton } from "~/utils/singleton"; + +export type ApiKeyOperation = "create" | "prepare_policy" | "revoke"; +export type ApiKeyOperationResult = "success" | "rejected" | "error"; +export type ApiKeyOperationReason = + | "none" + | "database_error" + | "not_found_or_revoked" + | "policy_rejected" + | "policy_error"; + +export type PublicTokenMintResult = "success" | "rejected" | "error"; +export type PublicTokenMintReason = + | "none" + | "invalid_body" + | "scope_not_allowed" + | "invalid_expiration" + | "expiration_not_future" + | "expiration_too_long" + | "signing_failed"; + +const telemetry = singleton("apiKeyTelemetry", () => { + const meter = getMeter("api-key"); + + return { + operations: meter.createCounter("api_key.operations", { + description: "Additional environment API key management operations", + }), + publicTokenMintAttempts: meter.createCounter("public_token.mint_attempts", { + description: "Public access token mint attempts using environment API keys", + }), + }; +}); + +export const apiKeyTelemetry = { + recordOperation( + operation: ApiKeyOperation, + result: ApiKeyOperationResult, + reason: ApiKeyOperationReason = "none" + ) { + telemetry.operations.add(1, { operation, result, reason }); + }, + recordPublicTokenMint(result: PublicTokenMintResult, reason: PublicTokenMintReason = "none") { + telemetry.publicTokenMintAttempts.add(1, { result, reason }); + }, +}; + +export type ApiKeyTelemetry = typeof apiKeyTelemetry; diff --git a/apps/webapp/app/services/publicTokens.server.ts b/apps/webapp/app/services/publicTokens.server.ts index b61ab4560f..590a7f3b2c 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 a9487018c1..3ebdc66b38 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 afb5b82d1f..f946475882 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"])