Skip to content

Commit a81ad49

Browse files
authored
feat(database,rbac): add multiple environment API key foundations (#4388)
Adds the storage model and authorization contracts needed for multiple environment API keys. Credentials are represented by hashed values, revocation and expiration state, and persisted effective scopes. The built-in authorization fallback exposes full-access policy preparation, while optional authorization extensions can supply additional presets and task-aware scope generation. This change does not create, display, or authenticate additional keys.
1 parent 4eb9292 commit a81ad49

13 files changed

Lines changed: 560 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Correct the `expirationTime` docs on `auth.createPublicToken` and the trigger-token helpers: a number is a Unix timestamp in seconds, not milliseconds.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, test } from "vitest";
2+
import {
3+
apiKeyPrefix,
4+
generateAdditionalApiKey,
5+
generateRootApiKey,
6+
hashApiKey,
7+
obfuscateApiKey,
8+
} from "./apiKeys";
9+
10+
describe("API key utilities", () => {
11+
test.each([
12+
["DEVELOPMENT", "tr_dev_"],
13+
["STAGING", "tr_stg_"],
14+
["PRODUCTION", "tr_prod_"],
15+
["PREVIEW", "tr_preview_"],
16+
] as const)("generates %s keys", (environmentType, prefix) => {
17+
const root = generateRootApiKey(environmentType);
18+
const additional = generateAdditionalApiKey(environmentType);
19+
20+
expect(root.apiKey).toMatch(new RegExp(`^${prefix}[A-Za-z0-9]{24}$`));
21+
expect(root.keyHash).toBe(hashApiKey(root.apiKey));
22+
expect(root.lastFour).toBe(root.apiKey.slice(-4));
23+
expect(additional.apiKey).toMatch(new RegExp(`^${prefix}sk_[A-Za-z0-9]{24}$`));
24+
expect(additional.keyHash).toBe(hashApiKey(additional.apiKey));
25+
expect(additional.lastFour).toBe(additional.apiKey.slice(-4));
26+
expect(apiKeyPrefix(environmentType)).toBe(prefix);
27+
expect(obfuscateApiKey(environmentType, root.lastFour)).toBe(
28+
`${prefix}••••••••${root.lastFour}`
29+
);
30+
expect(obfuscateApiKey(environmentType, additional.lastFour, "additional")).toBe(
31+
`${prefix}sk_••••••••${additional.lastFour}`
32+
);
33+
});
34+
35+
test("generates unique keys", () => {
36+
const keys = new Set(
37+
Array.from({ length: 100 }, () => generateAdditionalApiKey("PRODUCTION").apiKey)
38+
);
39+
40+
expect(keys.size).toBe(100);
41+
});
42+
});

apps/webapp/app/utils/apiKeys.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createHash } from "node:crypto";
2+
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
3+
import { customAlphabet } from "nanoid";
4+
5+
const apiKeyId = customAlphabet(
6+
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
7+
24
8+
);
9+
10+
export function hashApiKey(apiKey: string): string {
11+
return createHash("sha256").update(apiKey, "utf8").digest("hex");
12+
}
13+
14+
function generatedApiKey(apiKey: string) {
15+
return {
16+
apiKey,
17+
keyHash: hashApiKey(apiKey),
18+
lastFour: apiKey.slice(-4),
19+
};
20+
}
21+
22+
export function generateRootApiKey(environmentType: RuntimeEnvironmentType) {
23+
// Root keys intentionally use the same 24-character entropy as additional keys.
24+
return generatedApiKey(`${apiKeyPrefix(environmentType)}${apiKeyId()}`);
25+
}
26+
27+
export function generateAdditionalApiKey(environmentType: RuntimeEnvironmentType) {
28+
return generatedApiKey(`${apiKeyPrefix(environmentType)}sk_${apiKeyId()}`);
29+
}
30+
31+
export function apiKeyPrefix(environmentType: RuntimeEnvironmentType): string {
32+
switch (environmentType) {
33+
case "DEVELOPMENT":
34+
return "tr_dev_";
35+
case "STAGING":
36+
return "tr_stg_";
37+
case "PRODUCTION":
38+
return "tr_prod_";
39+
case "PREVIEW":
40+
return "tr_preview_";
41+
}
42+
}
43+
44+
export function obfuscateApiKey(
45+
environmentType: RuntimeEnvironmentType,
46+
lastFour: string,
47+
kind: "root" | "additional" = "root"
48+
): string {
49+
const discriminator = kind === "additional" ? "sk_" : "";
50+
return `${apiKeyPrefix(environmentType)}${discriminator}••••••••${lastFour}`;
51+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
-- CreateTable
2+
CREATE TABLE "public"."api_keys" (
3+
"id" TEXT NOT NULL,
4+
"name" TEXT NOT NULL,
5+
"key_hash" TEXT NOT NULL,
6+
"last_four" TEXT NOT NULL,
7+
"runtime_environment_id" TEXT NOT NULL,
8+
"created_by_user_id" TEXT,
9+
"preset_id" TEXT,
10+
"scopes" TEXT[] NOT NULL,
11+
"last_used_at" TIMESTAMP(3),
12+
"revoked_at" TIMESTAMP(3),
13+
"expires_at" TIMESTAMP(3),
14+
"updated_at" TIMESTAMP(3) NOT NULL,
15+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16+
17+
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
18+
);
19+
20+
-- CreateIndex
21+
CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "public"."api_keys"("key_hash");
22+
23+
-- CreateIndex
24+
CREATE INDEX "api_keys_runtime_environment_id_revoked_at_created_at_idx" ON "public"."api_keys"("runtime_environment_id", "revoked_at", "created_at" DESC);
25+
26+
-- AddForeignKey
27+
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;
28+
29+
-- AddForeignKey
30+
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;

internal-packages/database/prisma/schema.prisma

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ model User {
6969
invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id])
7070
invitationCodeId String?
7171
personalAccessTokens PersonalAccessToken[]
72+
createdApiKeys ApiKey[]
7273
deployments WorkerDeployment[]
7374
backupCodes MfaBackupCode[]
7475
bulkActions BulkActionGroup[]
@@ -392,6 +393,7 @@ model RuntimeEnvironment {
392393
playgroundConversations PlaygroundConversation[]
393394
errorGroupStates ErrorGroupState[]
394395
taskIdentifiers TaskIdentifier[]
396+
apiKeys ApiKey[]
395397
revokedApiKeys RevokedApiKey[]
396398
397399
// A partial unique index also enforces one STAGING/PREVIEW root per project and type.
@@ -403,6 +405,31 @@ model RuntimeEnvironment {
403405
@@index([organizationId])
404406
}
405407

408+
model ApiKey {
409+
id String @id @default(cuid())
410+
name String
411+
keyHash String @unique @map("key_hash")
412+
lastFour String @map("last_four")
413+
414+
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
415+
runtimeEnvironmentId String @map("runtime_environment_id")
416+
417+
createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull, onUpdate: Cascade)
418+
createdByUserId String? @map("created_by_user_id")
419+
420+
presetId String? @map("preset_id")
421+
scopes String[]
422+
423+
lastUsedAt DateTime? @map("last_used_at")
424+
revokedAt DateTime? @map("revoked_at")
425+
expiresAt DateTime? @map("expires_at")
426+
updatedAt DateTime @updatedAt @map("updated_at")
427+
createdAt DateTime @default(now()) @map("created_at")
428+
429+
@@index([runtimeEnvironmentId, revokedAt, createdAt(sort: Desc)])
430+
@@map("api_keys")
431+
}
432+
406433
/// Records of previously-valid API keys that are still accepted for authentication
407434
/// during a grace window after rotation. Extend or end the grace period by updating `expiresAt`.
408435
model RevokedApiKey {

internal-packages/rbac/src/ability.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
denyAbility,
66
buildFallbackAbility,
77
buildJwtAbility,
8+
scopesWithinAbility,
89
} from "./ability.js";
910

1011
describe("permissiveAbility", () => {
@@ -123,6 +124,43 @@ describe("buildJwtAbility", () => {
123124
});
124125
});
125126

127+
describe("scopesWithinAbility", () => {
128+
it("allows subsets and preserves ids containing colons", () => {
129+
const result = scopesWithinAbility(
130+
["read:runs:run_abc", "read:tags:env:staging"],
131+
buildJwtAbility(["read:runs", "read:tags:env:staging"])
132+
);
133+
134+
expect(result).toEqual({ ok: true, deniedScopes: [] });
135+
});
136+
137+
it("rejects scopes that broaden or exceed the ability", () => {
138+
const result = scopesWithinAbility(
139+
["trigger:tasks:send-email", "trigger:tasks", "read:runs"],
140+
buildJwtAbility(["trigger:tasks:send-email"])
141+
);
142+
143+
expect(result).toEqual({
144+
ok: false,
145+
deniedScopes: ["trigger:tasks", "read:runs"],
146+
});
147+
});
148+
149+
it("allows arbitrary valid scopes for a permissive ability", () => {
150+
expect(scopesWithinAbility(["read:runs", "admin"], permissiveAbility)).toEqual({
151+
ok: true,
152+
deniedScopes: [],
153+
});
154+
});
155+
156+
it("rejects malformed scopes for restricted abilities", () => {
157+
expect(scopesWithinAbility(["read"], buildJwtAbility(["read:all"]))).toEqual({
158+
ok: false,
159+
deniedScopes: ["read"],
160+
});
161+
});
162+
});
163+
126164
describe("buildJwtAbility — array resources", () => {
127165
it("authorizes when any resource in the array passes a scope check", () => {
128166
const ability = buildJwtAbility(["read:batch:batch_abc"]);

internal-packages/rbac/src/ability.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { RbacAbility } from "@trigger.dev/plugins";
44
// @trigger.dev/plugins so a public token decodes identically whoever
55
// serves the request. Re-exported here so existing importers keep their
66
// `./ability.js` import.
7-
export { buildJwtAbility } from "@trigger.dev/plugins";
7+
export { buildJwtAbility, scopesWithinAbility } from "@trigger.dev/plugins";
88

99
/** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */
1010
export const permissiveAbility: RbacAbility = {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { PrismaClient } from "@trigger.dev/database";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
// The API-key policy methods are OPTIONAL on RoleBaseAccessController so a
5+
// plugin compiled against an older OSS commit still satisfies the contract
6+
// (the plugin is built against whichever OSS source its base image carries).
7+
// LazyController is what turns that partial surface into a total one, and these
8+
// tests pin the defaults it substitutes — in particular that a missing
9+
// prepareApiKeyPolicy fails CLOSED rather than resolving to full access.
10+
//
11+
// A stand-in for the cloud plugin, which isn't installed in this repo. The
12+
// factory supplies the specifier, so no real module has to resolve.
13+
vi.mock("@triggerdotdev/plugins/rbac", () => ({
14+
default: {
15+
create: () => ({
16+
// Deliberately omits apiKeyPresets / prepareApiKeyPolicy /
17+
// describeApiKeyPolicy — this is a pre-contract plugin.
18+
isUsingPlugin: async () => true,
19+
}),
20+
},
21+
}));
22+
23+
const prismaPlaceholder = {} as unknown as PrismaClient;
24+
25+
describe("LazyController API-key policy defaults (plugin predates the contract)", () => {
26+
async function controller() {
27+
const loader = (await import("./index.js")).default;
28+
const instance = loader.create(prismaPlaceholder);
29+
// Guard against a silent fallback: if the mock didn't take, these
30+
// assertions would be checking the fallback's real implementations.
31+
await expect(instance.isUsingPlugin()).resolves.toBe(true);
32+
return instance;
33+
}
34+
35+
it("reports no preset catalogue rather than throwing", async () => {
36+
await expect((await controller()).apiKeyPresets("org_123")).resolves.toBeNull();
37+
});
38+
39+
it("refuses to prepare a policy — including FULL_ACCESS", async () => {
40+
const result = await (
41+
await controller()
42+
).prepareApiKeyPolicy({
43+
organizationId: "org_123",
44+
presetId: "FULL_ACCESS",
45+
});
46+
47+
// The critical assertion: absence must never resolve to `{ ok: true }` with
48+
// an admin scope. A plugin below the contract cannot mint any credential.
49+
expect(result.ok).toBe(false);
50+
expect(result).not.toHaveProperty("policy");
51+
});
52+
53+
it("describes a policy as having nothing extra to show", async () => {
54+
await expect(
55+
(await controller()).describeApiKeyPolicy({ presetId: "TRIGGER_ONLY", scopes: ["read:runs"] })
56+
).resolves.toEqual({});
57+
});
58+
});

internal-packages/rbac/src/fallback.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import type {
1313
RoleMutationResult,
1414
UserActorAuthResult,
1515
} from "@trigger.dev/plugins";
16-
import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/plugins";
16+
import {
17+
FULL_ACCESS_PRESET_ID,
18+
isUserActorToken,
19+
verifyUserActorToken,
20+
} from "@trigger.dev/plugins";
1721
import { createHash } from "node:crypto";
1822
import type { PrismaClient } from "@trigger.dev/database";
1923
import { validateJWT } from "@trigger.dev/core/v3/jwt";
@@ -374,6 +378,34 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
374378
return null;
375379
}
376380

381+
async apiKeyPresets(_organizationId: string) {
382+
return null;
383+
}
384+
385+
async prepareApiKeyPolicy(params: {
386+
organizationId: string;
387+
presetId: string;
388+
taskIdentifiers?: string[];
389+
}) {
390+
// Without a plugin there is no preset catalogue, so full access is the only
391+
// policy on offer, but the caller still has to ask for it by name. Any
392+
// other preset, or any task selection, is a restricted key and unavailable.
393+
if (params.presetId !== FULL_ACCESS_PRESET_ID || (params.taskIdentifiers?.length ?? 0) > 0) {
394+
return { ok: false as const, error: "API key access presets are not available" };
395+
}
396+
397+
// `presetId: null` because this install has no catalogue to reference. The
398+
// persisted scopes remain the source of truth for authorization.
399+
return {
400+
ok: true as const,
401+
policy: { presetId: null, scopes: ["admin"] },
402+
};
403+
}
404+
405+
async describeApiKeyPolicy() {
406+
return {};
407+
}
408+
377409
async allPermissions(): Promise<Permission[]> {
378410
return [];
379411
}

0 commit comments

Comments
 (0)