-
-
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
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,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. | ||
| 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. | ||
|
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. 🔍 Release note promises self-hosted key creation, but issuance defaults to off This note says self-hosted deployments "can now create multiple full-access API keys for each environment", but creation is gated by Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| 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. |
||
| 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"]) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Release notes will announce an SDK change that this update does not actually contain
A release note entry is added for the SDK package (
.changeset/public-token-expiration-seconds.md:1-5) even though this change set does not touch any SDK source or documentation files, so the next SDK release will be published with a note describing something that did not change.Impact: Users reading the SDK changelog will see an entry for a correction that was never shipped, and an otherwise unnecessary SDK version bump is triggered.
Why the changeset does not correspond to any change in this branch
The changeset declares
"@trigger.dev/sdk": patchand describes correcting theexpirationTimedocs onauth.createPublicTokenand the trigger-token helpers. However, the full diff against the merge base contains no files underpackages/trigger-sdk/(onlyapps/webapp/**,packages/plugins/src/rbac.ts, tests, and the two.server-changes/notes).packages/pluginsisprivate: true, so it needs no changeset either.CONTRIBUTING.md / AGENTS.md tie changesets to "contributing a change to any packages in this monorepo"; a changeset for a package with no changes produces a release and changelog entry with no corresponding code. Either include the intended
packages/trigger-sdkdoc edits (e.g. theexpirationTimeJSDoc atpackages/trigger-sdk/src/v3/auth.ts:131-134) or drop the changeset.Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.