From af05aeab8e795a4f71348c687f1e50c15f8d268b Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 12:51:12 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 89167bcad26900ac077ca59a5e4ddd75620a340c Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 27 Jul 2026 16:37:03 +0100 Subject: [PATCH 5/5] add changeset --- .changeset/jwt-payload-helpers.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/jwt-payload-helpers.md diff --git a/.changeset/jwt-payload-helpers.md b/.changeset/jwt-payload-helpers.md new file mode 100644 index 0000000000..cfc7bcaf3c --- /dev/null +++ b/.changeset/jwt-payload-helpers.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Expose helpers for identifying public JWTs and reading their subject claim.