-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): add multiple environment API key management #4390
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a792fb8
9b86901
38d22d6
bea204e
a534931
04e4402
d0f42d6
05e1397
4067b3d
60157ce
7582472
8ba4854
83bfc81
8092e9e
f520c53
89af992
87506ba
6872e2e
4f1ac83
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,18 @@ | ||
| import type { RuntimeEnvironment } from "@trigger.dev/database"; | ||
| import { prisma } from "~/db.server"; | ||
| import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database"; | ||
| 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 | ||
|
|
@@ -94,8 +103,175 @@ 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; | ||
| 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"); | ||
| } | ||
| } | ||
|
|
||
|
Comment on lines
+168
to
+182
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Task validation resolves identifiers against the requesting env, not the key env
Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+168
to
+182
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Task-existence check counts rows rather than matching distinct slugs
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| 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"]) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.