Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jwt-payload-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Expose helpers for identifying public JWTs and reading their subject claim.
5 changes: 5 additions & 0 deletions .changeset/public-token-expiration-seconds.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions apps/webapp/app/utils/apiKeys.test.ts
Original file line number Diff line number Diff line change
@@ -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}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);
expect(obfuscateApiKey(environmentType, root.lastFour)).toBe(
`${prefix}••••••••${root.lastFour}`
);
expect(obfuscateApiKey(environmentType, additional.lastFour, "additional")).toBe(
`${prefix}sk_••••••••${additional.lastFour}`
);
});

test("generates unique keys", () => {
const keys = new Set(
Array.from({ length: 100 }, () => generateAdditionalApiKey("PRODUCTION").apiKey)
);

expect(keys.size).toBe(100);
});
});
51 changes: 51 additions & 0 deletions apps/webapp/app/utils/apiKeys.ts
Original file line number Diff line number Diff line change
@@ -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");
Comment thread
carderne marked this conversation as resolved.
Dismissed
}

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)}sk_${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" ? "sk_" : "";
return `${apiKeyPrefix(environmentType)}${discriminator}••••••••${lastFour}`;
}
Original file line number Diff line number Diff line change
@@ -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;
27 changes: 27 additions & 0 deletions internal-packages/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions internal-packages/rbac/src/ability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
denyAbility,
buildFallbackAbility,
buildJwtAbility,
scopesWithinAbility,
} from "./ability.js";

describe("permissiveAbility", () => {
Expand Down Expand Up @@ -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"]);
Expand Down
2 changes: 1 addition & 1 deletion internal-packages/rbac/src/ability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
58 changes: 58 additions & 0 deletions internal-packages/rbac/src/apiKeyPolicyDefaults.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
34 changes: 33 additions & 1 deletion internal-packages/rbac/src/fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Permission[]> {
return [];
}
Expand Down
Loading
Loading