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;
139 changes: 136 additions & 3 deletions apps/webapp/app/models/api-key.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
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 { 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 +105,130 @@ 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,
}: {
prismaClient?: Pick<PrismaClient, "runtimeEnvironment" | "taskIdentifier" | "apiKey">;
rbacController?: Pick<HostRbacController, "prepareApiKeyPolicy">;
} = {}
) {
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");
}

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");
}
}

const prepared = await rbacController.prepareApiKeyPolicy({
organizationId: environment.organizationId,
presetId,
taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined,
});

if (!prepared.ok) {
throw new Error(prepared.error);
}

const generated = generateAdditionalApiKey(environment.type);
const apiKey = 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,
},
});

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;
}) {
const result = await prisma.apiKey.updateMany({
where: {
id: apiKeyId,
runtimeEnvironmentId: environmentId,
revokedAt: null,
},
data: { revokedAt: new Date() },
});

if (result.count !== 1) {
throw new Error("API key not found or already revoked");
}

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
167 changes: 121 additions & 46 deletions apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -1,88 +1,163 @@
import { type RuntimeEnvironment } from "@trigger.dev/database";
import { type PrismaClient, prisma } from "~/db.server";
// 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 these call sites guard-free.
import { scopesGrantFullAccess, type HostRbacController } from "@trigger.dev/rbac";
import { type PrismaReplicaClient, $replica } from "~/db.server";
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { rbac } from "~/services/rbac.server";
import { obfuscateApiKey } from "~/utils/apiKeys";

type ApiKeyPolicyPresenter = Pick<HostRbacController, "apiKeyPresets" | "describeApiKeyPolicy">;

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

constructor(prismaClient: PrismaClient = prisma) {
constructor(
prismaClient: PrismaReplicaClient = $replica,
rbacController: ApiKeyPolicyPresenter = rbac
) {
this.#prismaClient = prismaClient;
this.#rbac = rbacController;
}

public async call({
userId,
organizationSlug,
projectSlug,
environmentSlug,
showRevoked = false,
}: {
userId: User["id"];
organizationSlug: string;
projectSlug: Project["slug"];
environmentSlug: RuntimeEnvironment["slug"];
showRevoked?: boolean;
}) {
const environment = await this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
apiKey: true,
type: true,
slug: true,
updatedAt: true,
orgMember: {
select: {
userId: true,
},
},
branchName: true,
parentEnvironment: {
select: {
id: true,
apiKey: true,
},
},
project: {
select: {
id: true,
},
parentEnvironmentId: true,
taskIdentifiers: {
where: { isInLatestDeployment: true },
orderBy: { slug: "asc" },
select: { slug: true },
},
project: { select: { id: true } },
organizationId: true,
},
where: {
project: {
slug: projectSlug,
},
organization: {
members: {
some: {
userId,
},
},
},
project: { slug: projectSlug, organization: { slug: organizationSlug } },
organization: { slug: organizationSlug, members: { some: { userId } } },
slug: environmentSlug,
orgMember:
environmentSlug === "dev"
? {
userId,
}
: undefined,
OR: [{ type: { not: "DEVELOPMENT" } }, { type: "DEVELOPMENT", orgMember: { userId } }],
},
});

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

const vercelIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId: environment.project.id,
deletedAt: null,
organizationIntegration: { service: "VERCEL", deletedAt: null },
},
select: { id: true },
});
const keyEnvironmentId = environment.parentEnvironmentId ?? environment.id;

const [keyEnvironment, vercelIntegration] = await Promise.all([
this.#prismaClient.runtimeEnvironment.findUniqueOrThrow({
where: { id: keyEnvironmentId },
select: {
id: true,
apiKey: true,
type: true,
createdAt: true,
apiKeys: {
where: showRevoked ? undefined : { revokedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
lastFour: true,
presetId: true,
scopes: true,
lastUsedAt: true,
revokedAt: true,
expiresAt: true,
createdAt: true,
createdBy: {
select: {
id: true,
email: true,
name: true,
displayName: true,
},
},
},
},
},
}),
this.#prismaClient.organizationProjectIntegration.findFirst({
where: {
projectId: environment.project.id,
deletedAt: null,
organizationIntegration: { service: "VERCEL", deletedAt: null },
},
select: { id: true },
}),
]);

const [presets, policyDescriptions] = await Promise.all([
this.#rbac.apiKeyPresets(environment.organizationId),
Promise.all(
keyEnvironment.apiKeys.map((apiKey) =>
this.#rbac.describeApiKeyPolicy({
presetId: apiKey.presetId,
scopes: apiKey.scopes,
})
)
),
]);
const presetsById = new Map(presets?.map((preset) => [preset.id, preset]));
const { taskIdentifiers, organizationId: _organizationId, ...environmentData } = environment;

return {
environment: {
...environment,
apiKey: environment?.parentEnvironment?.apiKey ?? environment?.apiKey,
...environmentData,
apiKey: keyEnvironment.apiKey,
keyEnvironmentId,
},
availableTasks: taskIdentifiers.map((task) => task.slug),
rootApiKey: {
id: keyEnvironment.id,
name: "Root API key",
value: keyEnvironment.apiKey,
obfuscated: obfuscateApiKey(keyEnvironment.type, keyEnvironment.apiKey.slice(-4)),
createdAt: keyEnvironment.createdAt,
},
apiKeys: keyEnvironment.apiKeys.map((apiKey, index) => {
const { presetId, scopes, ...apiKeyData } = apiKey;
const description = policyDescriptions[index];
const preset = presetId ? presetsById.get(presetId) : undefined;
const isFullAccess = scopesGrantFullAccess(scopes);

return {
...apiKeyData,
access: {
presetId,
label: preset?.label ?? (presetId === null && isFullAccess ? "Full access" : "Custom"),
taskIdentifiers: description.taskIdentifiers,
usesTaskSelection:
preset?.usesTaskSelection ?? description.taskIdentifiers !== undefined,
},
obfuscated: obfuscateApiKey(keyEnvironment.type, apiKey.lastFour, "additional"),
};
}),
presets,
hasVercelIntegration: vercelIntegration !== null,
};
}
Expand Down
Loading
Loading