Skip to content

Commit 0e70a0b

Browse files
committed
feat(database,rbac): add multiple environment API key foundations
1 parent 82ccc62 commit 0e70a0b

11 files changed

Lines changed: 434 additions & 18 deletions

File tree

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}ak_[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}ak_••••••••${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)}ak_${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" ? "ak_" : "";
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 = {

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
}

internal-packages/rbac/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import type { PrismaClient } from "@trigger.dev/database";
1313
import { RoleBaseAccessFallback } from "./fallback.js";
1414
export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins";
1515
export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins";
16+
export { buildJwtAbility, scopesWithinAbility } from "./ability.js";
17+
export { FULL_ACCESS_PRESET_ID, scopesGrantFullAccess } from "@trigger.dev/plugins";
1618
// Re-export the user-actor token grammar so the webapp mints/checks tokens
1719
// through @trigger.dev/rbac (it doesn't import @trigger.dev/plugins directly).
1820
export {
@@ -213,6 +215,20 @@ class LazyController implements RoleBaseAccessController {
213215
return (await this.c()).systemRoles(...args);
214216
}
215217

218+
async apiKeyPresets(...args: Parameters<RoleBaseAccessController["apiKeyPresets"]>) {
219+
return (await this.c()).apiKeyPresets(...args);
220+
}
221+
222+
async prepareApiKeyPolicy(...args: Parameters<RoleBaseAccessController["prepareApiKeyPolicy"]>) {
223+
return (await this.c()).prepareApiKeyPolicy(...args);
224+
}
225+
226+
async describeApiKeyPolicy(
227+
...args: Parameters<RoleBaseAccessController["describeApiKeyPolicy"]>
228+
) {
229+
return (await this.c()).describeApiKeyPolicy(...args);
230+
}
231+
216232
async allPermissions(
217233
...args: Parameters<RoleBaseAccessController["allPermissions"]>
218234
): Promise<Permission[]> {

packages/core/src/v3/jwt.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,40 @@ export const JWT_ALGORITHM = "HS256";
1414
export const JWT_ISSUER = "https://id.trigger.dev";
1515
export const JWT_AUDIENCE = "https://api.trigger.dev";
1616

17+
function decodeJWTPayload(token: string): unknown {
18+
const parts = token.split(".");
19+
const encodedPayload = parts[1];
20+
if (parts.length !== 3 || !encodedPayload) return;
21+
22+
try {
23+
const base64 = encodedPayload
24+
.replace(/-/g, "+")
25+
.replace(/_/g, "/")
26+
.padEnd(Math.ceil(encodedPayload.length / 4) * 4, "=");
27+
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
28+
return JSON.parse(new TextDecoder().decode(bytes));
29+
} catch {
30+
return;
31+
}
32+
}
33+
34+
export function isPublicJWT(token: string): boolean {
35+
const payload = decodeJWTPayload(token);
36+
return (
37+
payload !== null && typeof payload === "object" && "pub" in payload && payload.pub === true
38+
);
39+
}
40+
41+
export function extractJWTSub(token: string): string | undefined {
42+
const payload = decodeJWTPayload(token);
43+
return payload !== null &&
44+
typeof payload === "object" &&
45+
"sub" in payload &&
46+
typeof payload.sub === "string"
47+
? payload.sub
48+
: undefined;
49+
}
50+
1751
export async function generateJWT(options: GenerateJWTOptions): Promise<string> {
1852
const { SignJWT } = await import("jose");
1953

packages/plugins/src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,22 @@ export type {
1818
RbacPluginConfig,
1919
RbacDatabaseConfig,
2020
SystemRole,
21+
ApiKeyPreset,
22+
ApiKeyPolicy,
23+
PrepareApiKeyPolicyResult,
24+
ApiKeyPolicyDescription,
2125
AuthenticatedEnvironment,
26+
RbacScopeAction,
27+
RbacScopeResourceType,
2228
} from "./rbac.js";
2329

24-
export { buildJwtAbility } from "./rbac.js";
30+
export {
31+
buildJwtAbility,
32+
buildScope,
33+
FULL_ACCESS_PRESET_ID,
34+
scopesGrantFullAccess,
35+
scopesWithinAbility,
36+
} from "./rbac.js";
2537
export {
2638
isUserActorToken,
2739
signUserActorToken,

0 commit comments

Comments
 (0)