Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .server-changes/api-key-deploy-envvars-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Self-hosted deployments can now create multiple full-access API keys for each environment.
6 changes: 6 additions & 0 deletions .server-changes/public-token-additional-api-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Additional environment API keys can now create scoped public access tokens.
1 change: 1 addition & 0 deletions apps/webapp/app/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const RUN_CHUNK_EXECUTION_BUFFER = 350;
export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
export const MAX_BATCH_TRIGGER_ITEMS = 100;
export const MAX_API_KEY_TASK_IDENTIFIERS = 10;
export const MAX_TASK_RUN_ATTEMPTS = 250;
export const BULK_ACTION_RUN_LIMIT = 250;
export const MAX_JOB_RUN_EXECUTION_COUNT = 250;
189 changes: 186 additions & 3 deletions apps/webapp/app/models/api-key.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import type { RuntimeEnvironment } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database";
// HostRbacController, not RoleBaseAccessController: the policy methods are
// optional on the plugin-facing contract, and `rbac` (LazyController) has
// already substituted the fail-closed defaults for any an installed plugin
// omits. Depending on the host surface keeps this call site guard-free.
import type { HostRbacController } from "@trigger.dev/rbac";
import { trail } from "agentcrumbs"; // @crumbs
import { customAlphabet } from "nanoid";
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
import { prisma } from "~/db.server";
import { RuntimeEnvironmentType } from "~/database-types";
import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server";
import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
import { rbac } from "~/services/rbac.server";
import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";

const crumb = trail("webapp"); // @crumbs

const apiKeyId = customAlphabet(
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
12
Expand Down Expand Up @@ -94,8 +107,178 @@ export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIK
return updatedEnviroment;
}

export async function createEnvironmentApiKey(
{
environmentId,
taskEnvironmentId,
userId,
name,
expiresAt,
presetId,
taskIdentifiers,
}: {
environmentId: string;
taskEnvironmentId: string;
userId: string;
name: string;
expiresAt?: Date;
// Required, and passed straight through to `prepareApiKeyPolicy` — callers
// name the access level rather than leaning on a default that would grant
// full access. Installs with no preset catalogue pass FULL_ACCESS_PRESET_ID.
presetId: string;
taskIdentifiers?: string[];
},
{
prismaClient = prisma,
rbacController = rbac,
issuanceAllowed,
telemetryRecorder = apiKeyTelemetry,
}: {
prismaClient?: Pick<
PrismaClient,
"apiKey" | "featureFlag" | "organization" | "runtimeEnvironment" | "taskIdentifier"
>;
rbacController?: Pick<HostRbacController, "prepareApiKeyPolicy">;
issuanceAllowed?: (organizationId: string) => Promise<boolean>;
telemetryRecorder?: ApiKeyTelemetry;
} = {}
) {
const environment = await prismaClient.runtimeEnvironment.findFirst({
where: {
id: environmentId,
organization: { members: { some: { userId } } },
},
select: { id: true, type: true, organizationId: true },
});

if (!environment) {
throw new Error("Environment not found");
}

const canIssue =
issuanceAllowed ??
((organizationId) => canIssueAdditionalApiKeys(organizationId, prismaClient));
if (!(await canIssue(environment.organizationId))) {
throw new Error("Creating additional API keys is not enabled.");
}

if (expiresAt && expiresAt.getTime() <= Date.now()) {
throw new Error("Expiration must be in the future");
}

const selectedTasks = [...new Set(taskIdentifiers?.map((task) => task.trim()).filter(Boolean))];

if (selectedTasks.length > MAX_API_KEY_TASK_IDENTIFIERS) {
throw new Error(`You can select at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks for an API key`);
}
if (selectedTasks.length > 0) {
const matchingTasks = await prismaClient.taskIdentifier.count({
where: {
runtimeEnvironmentId: taskEnvironmentId,
slug: { in: selectedTasks },
runtimeEnvironment: {
OR: [{ id: environment.id }, { parentEnvironmentId: environment.id }],
},
},
});

if (matchingTasks !== selectedTasks.length) {
throw new Error("One or more selected tasks are not available in this environment");
}
}

let prepared: Awaited<ReturnType<typeof rbacController.prepareApiKeyPolicy>>;
try {
prepared = await rbacController.prepareApiKeyPolicy({
organizationId: environment.organizationId,
presetId,
taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined,
});
} catch (error) {
telemetryRecorder.recordOperation("prepare_policy", "error", "policy_error");
throw error;
}

if (!prepared.ok) {
telemetryRecorder.recordOperation("prepare_policy", "rejected", "policy_rejected");
throw new Error(prepared.error);
}
telemetryRecorder.recordOperation("prepare_policy", "success");

const generated = generateAdditionalApiKey(environment.type);
const apiKey = await (async () => {
try {
return await prismaClient.apiKey.create({
data: {
name,
keyHash: generated.keyHash,
lastFour: generated.lastFour,
runtimeEnvironmentId: environment.id,
createdByUserId: userId,
expiresAt,
presetId: prepared.policy.presetId,
scopes: prepared.policy.scopes,
},
});
} catch (error) {
telemetryRecorder.recordOperation("create", "error", "database_error");
throw error;
}
})();
telemetryRecorder.recordOperation("create", "success");

crumb("environment API key created", {
apiKeyId: apiKey.id,
environmentId,
presetId: apiKey.presetId,
}); // @crumbs

return { apiKey, plaintext: generated.apiKey };
}

export async function revokeEnvironmentApiKey(
{
environmentId,
apiKeyId,
}: {
environmentId: string;
apiKeyId: string;
},
{
prismaClient = prisma,
telemetryRecorder = apiKeyTelemetry,
}: {
prismaClient?: Pick<PrismaClient, "apiKey">;
telemetryRecorder?: ApiKeyTelemetry;
} = {}
) {
const result = await (async () => {
try {
return await prismaClient.apiKey.updateMany({
where: {
id: apiKeyId,
runtimeEnvironmentId: environmentId,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
} catch (error) {
telemetryRecorder.recordOperation("revoke", "error", "database_error");
throw error;
}
})();

if (result.count !== 1) {
telemetryRecorder.recordOperation("revoke", "rejected", "not_found_or_revoked");
throw new Error("API key not found or already revoked");
}

telemetryRecorder.recordOperation("revoke", "success");
crumb("environment API key revoked", { apiKeyId, environmentId }); // @crumbs
}

export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
return generateRootApiKey(envType).apiKey;
}

export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
Expand Down
Loading