Skip to content

Commit 6aba247

Browse files
committed
feat(webapp): add multiple environment API key management
1 parent 37de624 commit 6aba247

10 files changed

Lines changed: 1930 additions & 165 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Self-hosted deployments can now create multiple full-access API keys for each environment.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Additional environment API keys can now create scoped public access tokens.

apps/webapp/app/models/api-key.server.ts

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
import type { RuntimeEnvironment } from "@trigger.dev/database";
2-
import { prisma } from "~/db.server";
1+
import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database";
2+
import type { RoleBaseAccessController } from "@trigger.dev/rbac";
3+
import { trail } from "agentcrumbs"; // @crumbs
34
import { customAlphabet } from "nanoid";
5+
import { prisma } from "~/db.server";
46
import { RuntimeEnvironmentType } from "~/database-types";
7+
import { rbac } from "~/services/rbac.server";
8+
import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys";
59
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
610

11+
const crumb = trail("webapp"); // @crumbs
12+
13+
export const MAX_API_KEY_TASK_IDENTIFIERS = 10;
14+
715
const apiKeyId = customAlphabet(
816
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
917
12
@@ -94,8 +102,130 @@ export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIK
94102
return updatedEnviroment;
95103
}
96104

105+
export async function createEnvironmentApiKey(
106+
{
107+
environmentId,
108+
taskEnvironmentId,
109+
userId,
110+
name,
111+
expiresAt,
112+
presetId,
113+
taskIdentifiers,
114+
}: {
115+
environmentId: string;
116+
taskEnvironmentId: string;
117+
userId: string;
118+
name: string;
119+
expiresAt?: Date;
120+
// Required, and passed straight through to `prepareApiKeyPolicy` — callers
121+
// name the access level rather than leaning on a default that would grant
122+
// full access. Installs with no preset catalogue pass FULL_ACCESS_PRESET_ID.
123+
presetId: string;
124+
taskIdentifiers?: string[];
125+
},
126+
{
127+
prismaClient = prisma,
128+
rbacController = rbac,
129+
}: {
130+
prismaClient?: Pick<PrismaClient, "runtimeEnvironment" | "taskIdentifier" | "apiKey">;
131+
rbacController?: Pick<RoleBaseAccessController, "prepareApiKeyPolicy">;
132+
} = {}
133+
) {
134+
const environment = await prismaClient.runtimeEnvironment.findFirst({
135+
where: {
136+
id: environmentId,
137+
organization: { members: { some: { userId } } },
138+
},
139+
select: { id: true, type: true, organizationId: true },
140+
});
141+
142+
if (!environment) {
143+
throw new Error("Environment not found");
144+
}
145+
146+
if (expiresAt && expiresAt.getTime() <= Date.now()) {
147+
throw new Error("Expiration must be in the future");
148+
}
149+
150+
const selectedTasks = [...new Set(taskIdentifiers?.map((task) => task.trim()).filter(Boolean))];
151+
152+
if (selectedTasks.length > MAX_API_KEY_TASK_IDENTIFIERS) {
153+
throw new Error(`You can select at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks for an API key`);
154+
}
155+
if (selectedTasks.length > 0) {
156+
const matchingTasks = await prismaClient.taskIdentifier.count({
157+
where: {
158+
runtimeEnvironmentId: taskEnvironmentId,
159+
slug: { in: selectedTasks },
160+
runtimeEnvironment: {
161+
OR: [{ id: environment.id }, { parentEnvironmentId: environment.id }],
162+
},
163+
},
164+
});
165+
166+
if (matchingTasks !== selectedTasks.length) {
167+
throw new Error("One or more selected tasks are not available in this environment");
168+
}
169+
}
170+
171+
const prepared = await rbacController.prepareApiKeyPolicy({
172+
organizationId: environment.organizationId,
173+
presetId,
174+
taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined,
175+
});
176+
177+
if (!prepared.ok) {
178+
throw new Error(prepared.error);
179+
}
180+
181+
const generated = generateAdditionalApiKey(environment.type);
182+
const apiKey = await prismaClient.apiKey.create({
183+
data: {
184+
name,
185+
keyHash: generated.keyHash,
186+
lastFour: generated.lastFour,
187+
runtimeEnvironmentId: environment.id,
188+
createdByUserId: userId,
189+
expiresAt,
190+
presetId: prepared.policy.presetId,
191+
scopes: prepared.policy.scopes,
192+
},
193+
});
194+
195+
crumb("environment API key created", {
196+
apiKeyId: apiKey.id,
197+
environmentId,
198+
presetId: apiKey.presetId,
199+
}); // @crumbs
200+
201+
return { apiKey, plaintext: generated.apiKey };
202+
}
203+
204+
export async function revokeEnvironmentApiKey({
205+
environmentId,
206+
apiKeyId,
207+
}: {
208+
environmentId: string;
209+
apiKeyId: string;
210+
}) {
211+
const result = await prisma.apiKey.updateMany({
212+
where: {
213+
id: apiKeyId,
214+
runtimeEnvironmentId: environmentId,
215+
revokedAt: null,
216+
},
217+
data: { revokedAt: new Date() },
218+
});
219+
220+
if (result.count !== 1) {
221+
throw new Error("API key not found or already revoked");
222+
}
223+
224+
crumb("environment API key revoked", { apiKeyId, environmentId }); // @crumbs
225+
}
226+
97227
export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
98-
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
228+
return generateRootApiKey(envType).apiKey;
99229
}
100230

101231
export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {

apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts

Lines changed: 120 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,162 @@
11
import { type RuntimeEnvironment } from "@trigger.dev/database";
2-
import { type PrismaClient, prisma } from "~/db.server";
2+
import { scopesGrantFullAccess, type RoleBaseAccessController } from "@trigger.dev/rbac";
3+
import { type PrismaReplicaClient, $replica } from "~/db.server";
34
import { type Project } from "~/models/project.server";
45
import { type User } from "~/models/user.server";
6+
import { rbac } from "~/services/rbac.server";
7+
import { obfuscateApiKey } from "~/utils/apiKeys";
8+
9+
type ApiKeyPolicyPresenter = Pick<
10+
RoleBaseAccessController,
11+
"apiKeyPresets" | "describeApiKeyPolicy"
12+
>;
513

614
export class ApiKeysPresenter {
7-
#prismaClient: PrismaClient;
15+
// Read-only presenter for a dashboard page — all queries below are reads, so
16+
// default to the replica and keep this off the writer.
17+
#prismaClient: PrismaReplicaClient;
18+
#rbac: ApiKeyPolicyPresenter;
819

9-
constructor(prismaClient: PrismaClient = prisma) {
20+
constructor(
21+
prismaClient: PrismaReplicaClient = $replica,
22+
rbacController: ApiKeyPolicyPresenter = rbac
23+
) {
1024
this.#prismaClient = prismaClient;
25+
this.#rbac = rbacController;
1126
}
1227

1328
public async call({
1429
userId,
30+
organizationSlug,
1531
projectSlug,
1632
environmentSlug,
33+
showRevoked = false,
1734
}: {
1835
userId: User["id"];
36+
organizationSlug: string;
1937
projectSlug: Project["slug"];
2038
environmentSlug: RuntimeEnvironment["slug"];
39+
showRevoked?: boolean;
2140
}) {
2241
const environment = await this.#prismaClient.runtimeEnvironment.findFirst({
2342
select: {
2443
id: true,
25-
apiKey: true,
2644
type: true,
2745
slug: true,
28-
updatedAt: true,
29-
orgMember: {
30-
select: {
31-
userId: true,
32-
},
33-
},
3446
branchName: true,
35-
parentEnvironment: {
36-
select: {
37-
id: true,
38-
apiKey: true,
39-
},
40-
},
41-
project: {
42-
select: {
43-
id: true,
44-
},
47+
parentEnvironmentId: true,
48+
taskIdentifiers: {
49+
where: { isInLatestDeployment: true },
50+
orderBy: { slug: "asc" },
51+
select: { slug: true },
4552
},
53+
project: { select: { id: true } },
54+
organizationId: true,
4655
},
4756
where: {
48-
project: {
49-
slug: projectSlug,
50-
},
51-
organization: {
52-
members: {
53-
some: {
54-
userId,
55-
},
56-
},
57-
},
57+
project: { slug: projectSlug, organization: { slug: organizationSlug } },
58+
organization: { slug: organizationSlug, members: { some: { userId } } },
5859
slug: environmentSlug,
59-
orgMember:
60-
environmentSlug === "dev"
61-
? {
62-
userId,
63-
}
64-
: undefined,
60+
OR: [{ type: { not: "DEVELOPMENT" } }, { type: "DEVELOPMENT", orgMember: { userId } }],
6561
},
6662
});
6763

6864
if (!environment) {
6965
throw new Error("Environment not found");
7066
}
7167

72-
const vercelIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
73-
where: {
74-
projectId: environment.project.id,
75-
deletedAt: null,
76-
organizationIntegration: { service: "VERCEL", deletedAt: null },
77-
},
78-
select: { id: true },
79-
});
68+
const keyEnvironmentId = environment.parentEnvironmentId ?? environment.id;
69+
70+
const [keyEnvironment, vercelIntegration] = await Promise.all([
71+
this.#prismaClient.runtimeEnvironment.findUniqueOrThrow({
72+
where: { id: keyEnvironmentId },
73+
select: {
74+
id: true,
75+
apiKey: true,
76+
type: true,
77+
createdAt: true,
78+
apiKeys: {
79+
where: showRevoked ? undefined : { revokedAt: null },
80+
orderBy: { createdAt: "desc" },
81+
select: {
82+
id: true,
83+
name: true,
84+
lastFour: true,
85+
presetId: true,
86+
scopes: true,
87+
lastUsedAt: true,
88+
revokedAt: true,
89+
expiresAt: true,
90+
createdAt: true,
91+
createdBy: {
92+
select: {
93+
id: true,
94+
email: true,
95+
name: true,
96+
displayName: true,
97+
},
98+
},
99+
},
100+
},
101+
},
102+
}),
103+
this.#prismaClient.organizationProjectIntegration.findFirst({
104+
where: {
105+
projectId: environment.project.id,
106+
deletedAt: null,
107+
organizationIntegration: { service: "VERCEL", deletedAt: null },
108+
},
109+
select: { id: true },
110+
}),
111+
]);
112+
113+
const [presets, policyDescriptions] = await Promise.all([
114+
this.#rbac.apiKeyPresets(environment.organizationId),
115+
Promise.all(
116+
keyEnvironment.apiKeys.map((apiKey) =>
117+
this.#rbac.describeApiKeyPolicy({
118+
presetId: apiKey.presetId,
119+
scopes: apiKey.scopes,
120+
})
121+
)
122+
),
123+
]);
124+
const presetsById = new Map(presets?.map((preset) => [preset.id, preset]));
125+
const { taskIdentifiers, organizationId: _organizationId, ...environmentData } = environment;
80126

81127
return {
82128
environment: {
83-
...environment,
84-
apiKey: environment?.parentEnvironment?.apiKey ?? environment?.apiKey,
129+
...environmentData,
130+
apiKey: keyEnvironment.apiKey,
131+
keyEnvironmentId,
132+
},
133+
availableTasks: taskIdentifiers.map((task) => task.slug),
134+
rootApiKey: {
135+
id: keyEnvironment.id,
136+
name: "Root API key",
137+
value: keyEnvironment.apiKey,
138+
obfuscated: obfuscateApiKey(keyEnvironment.type, keyEnvironment.apiKey.slice(-4)),
139+
createdAt: keyEnvironment.createdAt,
85140
},
141+
apiKeys: keyEnvironment.apiKeys.map((apiKey, index) => {
142+
const { presetId, scopes, ...apiKeyData } = apiKey;
143+
const description = policyDescriptions[index];
144+
const preset = presetId ? presetsById.get(presetId) : undefined;
145+
const isFullAccess = scopesGrantFullAccess(scopes);
146+
147+
return {
148+
...apiKeyData,
149+
access: {
150+
presetId,
151+
label: preset?.label ?? (presetId === null && isFullAccess ? "Full access" : "Custom"),
152+
taskIdentifiers: description.taskIdentifiers,
153+
usesTaskSelection:
154+
preset?.usesTaskSelection ?? description.taskIdentifiers !== undefined,
155+
},
156+
obfuscated: obfuscateApiKey(keyEnvironment.type, apiKey.lastFour, "additional"),
157+
};
158+
}),
159+
presets,
86160
hasVercelIntegration: vercelIntegration !== null,
87161
};
88162
}

0 commit comments

Comments
 (0)