Skip to content
5 changes: 5 additions & 0 deletions .changeset/public-token-expiration-seconds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Correct the `expirationTime` docs on `auth.createPublicToken` and the trigger-token helpers: a number is a Unix timestamp in seconds, not milliseconds.
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
Loading