Skip to content

Commit 9409ddf

Browse files
authored
feat(webapp): add multiple environment API key management (#4390)
## Summary Projects can create, inspect, expire, and revoke multiple API keys for each environment. Plaintext values are shown only at creation; stored credentials are hashed and the API keys page displays only an obfuscated suffix afterward. Self-hosted installations support full-access additional keys by default. Authorization extensions can provide additional access presets and optional task selection. Additional keys can also mint scoped public access tokens through the Trigger.dev API without receiving the environment signing key. ## Feature notes - Only admin+ can create API keys (Developer can make in Development branch). - JWT self-signing will be a server call when used with new `_ak_` keys. - JWTs with long expiry can keep working even with api key deleted (gets priveleges from api key, signed with root key) - Unfiltered session listings intentionally preserve the existing broad task-read behavior. Filtered listings enforce task-level scopes for every requested task. - Buffered runs without a task identifier are not safely authorizable, so cancel/replay requests fail closed rather than resolving an unscoped run. - Batch and waitpoint endpoints intentionally return server-minted, narrowly scoped public tokens to all callers. These tokens have bounded lifetimes and may remain valid until expiry after API-key revocation. ## Deployment notes Deploy the management UI and public-token endpoint with new key creation disabled. Enable creation for selected organizations after the authentication path and released SDK have been verified, then expand availability gradually. Revoking an API key prevents new bearer requests and new token minting. Public tokens already minted by that key remain valid until their own expiration because they are signed by the environment signing key. ## TODO - [x] Add "Created by" to the key table - [x] Document that streamed batch ingestion is non-atomic and may partially accept items before a validation or authorization error. ## Follow-ups - [x] Add an organization-level feature flag for the API key management UI and creation action. - [x] Document rollout ordering: enable additional-key lookup before enabling issuance. - [x] Add a system-wide gate that can stop new key issuance without disabling authentication for existing keys. - [x] Replace the generic SDK compatibility warning with the first published compatible version. Old SDK will mint an unusable token if given an `_ak_` key. - [x] Add public documentation covering creation, storage, expiration, revocation, SDK compatibility, and public-token lifetime behavior. - [x] Add observability for key creation, revocation, policy preparation failures, and public-token mint failures. - [ ] Exercise create, copy-once display, authenticate, mint, expire, and revoke flows end to end before broad enablement.
1 parent 337dda1 commit 9409ddf

22 files changed

Lines changed: 2874 additions & 363 deletions
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/consts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const RUN_CHUNK_EXECUTION_BUFFER = 350;
1010
export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
1111
export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
1212
export const MAX_BATCH_TRIGGER_ITEMS = 100;
13+
export const MAX_API_KEY_TASK_IDENTIFIERS = 10;
1314
export const MAX_TASK_RUN_ATTEMPTS = 250;
1415
export const BULK_ACTION_RUN_LIMIT = 250;
1516
export const MAX_JOB_RUN_EXECUTION_COUNT = 250;

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

Lines changed: 179 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
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 { HostRbacController } from "@trigger.dev/rbac";
3+
import { trail } from "agentcrumbs"; // @crumbs
34
import { customAlphabet } from "nanoid";
5+
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
6+
import { prisma } from "~/db.server";
47
import { RuntimeEnvironmentType } from "~/database-types";
8+
import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server";
9+
import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
10+
import { rbac } from "~/services/rbac.server";
11+
import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys";
512
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
613

14+
const crumb = trail("webapp"); // @crumbs
15+
716
const apiKeyId = customAlphabet(
817
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
918
12
@@ -94,8 +103,175 @@ export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIK
94103
return updatedEnviroment;
95104
}
96105

106+
export async function createEnvironmentApiKey(
107+
{
108+
environmentId,
109+
taskEnvironmentId,
110+
userId,
111+
name,
112+
expiresAt,
113+
presetId,
114+
taskIdentifiers,
115+
}: {
116+
environmentId: string;
117+
taskEnvironmentId: string;
118+
userId: string;
119+
name: string;
120+
expiresAt?: Date;
121+
presetId: string;
122+
taskIdentifiers?: string[];
123+
},
124+
{
125+
prismaClient = prisma,
126+
rbacController = rbac,
127+
issuanceAllowed,
128+
telemetryRecorder = apiKeyTelemetry,
129+
}: {
130+
prismaClient?: Pick<
131+
PrismaClient,
132+
"apiKey" | "featureFlag" | "organization" | "runtimeEnvironment" | "taskIdentifier"
133+
>;
134+
rbacController?: Pick<HostRbacController, "prepareApiKeyPolicy">;
135+
issuanceAllowed?: (organizationId: string) => Promise<boolean>;
136+
telemetryRecorder?: ApiKeyTelemetry;
137+
} = {}
138+
) {
139+
const environment = await prismaClient.runtimeEnvironment.findFirst({
140+
where: {
141+
id: environmentId,
142+
organization: { members: { some: { userId } } },
143+
},
144+
select: { id: true, type: true, organizationId: true },
145+
});
146+
147+
if (!environment) {
148+
throw new Error("Environment not found");
149+
}
150+
151+
const canIssue =
152+
issuanceAllowed ??
153+
((organizationId) => canIssueAdditionalApiKeys(organizationId, prismaClient));
154+
if (!(await canIssue(environment.organizationId))) {
155+
throw new Error("Creating additional API keys is not enabled.");
156+
}
157+
158+
if (expiresAt && expiresAt.getTime() <= Date.now()) {
159+
throw new Error("Expiration must be in the future");
160+
}
161+
162+
const selectedTasks = [...new Set(taskIdentifiers?.map((task) => task.trim()).filter(Boolean))];
163+
164+
if (selectedTasks.length > MAX_API_KEY_TASK_IDENTIFIERS) {
165+
throw new Error(`You can select at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks for an API key`);
166+
}
167+
if (selectedTasks.length > 0) {
168+
const matchingTasks = await prismaClient.taskIdentifier.count({
169+
where: {
170+
runtimeEnvironmentId: taskEnvironmentId,
171+
slug: { in: selectedTasks },
172+
runtimeEnvironment: {
173+
OR: [{ id: environment.id }, { parentEnvironmentId: environment.id }],
174+
},
175+
},
176+
});
177+
178+
if (matchingTasks !== selectedTasks.length) {
179+
throw new Error("One or more selected tasks are not available in this environment");
180+
}
181+
}
182+
183+
let prepared: Awaited<ReturnType<typeof rbacController.prepareApiKeyPolicy>>;
184+
try {
185+
prepared = await rbacController.prepareApiKeyPolicy({
186+
organizationId: environment.organizationId,
187+
presetId,
188+
taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined,
189+
});
190+
} catch (error) {
191+
telemetryRecorder.recordOperation("prepare_policy", "error", "policy_error");
192+
throw error;
193+
}
194+
195+
if (!prepared.ok) {
196+
telemetryRecorder.recordOperation("prepare_policy", "rejected", "policy_rejected");
197+
throw new Error(prepared.error);
198+
}
199+
telemetryRecorder.recordOperation("prepare_policy", "success");
200+
201+
const generated = generateAdditionalApiKey(environment.type);
202+
const apiKey = await (async () => {
203+
try {
204+
return await prismaClient.apiKey.create({
205+
data: {
206+
name,
207+
keyHash: generated.keyHash,
208+
lastFour: generated.lastFour,
209+
runtimeEnvironmentId: environment.id,
210+
createdByUserId: userId,
211+
expiresAt,
212+
presetId: prepared.policy.presetId,
213+
scopes: prepared.policy.scopes,
214+
},
215+
});
216+
} catch (error) {
217+
telemetryRecorder.recordOperation("create", "error", "database_error");
218+
throw error;
219+
}
220+
})();
221+
telemetryRecorder.recordOperation("create", "success");
222+
223+
crumb("environment API key created", {
224+
apiKeyId: apiKey.id,
225+
environmentId,
226+
presetId: apiKey.presetId,
227+
}); // @crumbs
228+
229+
return { apiKey, plaintext: generated.apiKey };
230+
}
231+
232+
export async function revokeEnvironmentApiKey(
233+
{
234+
environmentId,
235+
apiKeyId,
236+
}: {
237+
environmentId: string;
238+
apiKeyId: string;
239+
},
240+
{
241+
prismaClient = prisma,
242+
telemetryRecorder = apiKeyTelemetry,
243+
}: {
244+
prismaClient?: Pick<PrismaClient, "apiKey">;
245+
telemetryRecorder?: ApiKeyTelemetry;
246+
} = {}
247+
) {
248+
const result = await (async () => {
249+
try {
250+
return await prismaClient.apiKey.updateMany({
251+
where: {
252+
id: apiKeyId,
253+
runtimeEnvironmentId: environmentId,
254+
revokedAt: null,
255+
},
256+
data: { revokedAt: new Date() },
257+
});
258+
} catch (error) {
259+
telemetryRecorder.recordOperation("revoke", "error", "database_error");
260+
throw error;
261+
}
262+
})();
263+
264+
if (result.count !== 1) {
265+
telemetryRecorder.recordOperation("revoke", "rejected", "not_found_or_revoked");
266+
throw new Error("API key not found or already revoked");
267+
}
268+
269+
telemetryRecorder.recordOperation("revoke", "success");
270+
crumb("environment API key revoked", { apiKeyId, environmentId }); // @crumbs
271+
}
272+
97273
export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
98-
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
274+
return generateRootApiKey(envType).apiKey;
99275
}
100276

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

0 commit comments

Comments
 (0)