Files
triggerdotdev--trigger.dev/apps/webapp/test/createEnvironmentApiKey.test.ts
T
Chris Arderne 9409ddf9bc feat(webapp): add multiple environment API key management (#4390)
## Summary

Projects can create, inspect, expire, and revoke multiple API keys for
each environment. Plaintext values are shown only at creation; stored
credentials are hashed and the API keys page displays only an obfuscated
suffix afterward.

Self-hosted installations support full-access additional keys by
default. Authorization extensions can provide additional access presets
and optional task selection. Additional keys can also mint scoped public
access tokens through the Trigger.dev API without receiving the
environment signing key.

## Feature notes
- Only admin+ can create API keys (Developer can make in Development
branch).
- JWT self-signing will be a server call when used with new `_ak_` keys.
- JWTs with long expiry can keep working even with api key deleted (gets
priveleges from api key, signed with root key)
- Unfiltered session listings intentionally preserve the existing broad
task-read behavior. Filtered listings enforce task-level scopes for
every requested task.
- Buffered runs without a task identifier are not safely authorizable,
so cancel/replay requests fail closed rather than resolving an unscoped
run.
- Batch and waitpoint endpoints intentionally return server-minted,
narrowly scoped public tokens to all callers. These tokens have bounded
lifetimes and may remain valid until expiry after API-key revocation.

## Deployment notes

Deploy the management UI and public-token endpoint with new key creation
disabled. Enable creation for selected organizations after the
authentication path and released SDK have been verified, then expand
availability gradually.

Revoking an API key prevents new bearer requests and new token minting.
Public tokens already minted by that key remain valid until their own
expiration because they are signed by the environment signing key.

## TODO
- [x] Add "Created by" to the key table
- [x] Document that streamed batch ingestion is non-atomic and may
 partially accept items before a validation or authorization error.

## Follow-ups

- [x] Add an organization-level feature flag for the API key management
UI and creation action.
- [x] Document rollout ordering: enable additional-key lookup before
enabling issuance.
- [x] Add a system-wide gate that can stop new key issuance without
disabling authentication for existing keys.
- [x] Replace the generic SDK compatibility warning with the first
published compatible version. Old SDK will mint an unusable token if
given an `_ak_` key.
- [x] Add public documentation covering creation, storage, expiration,
revocation, SDK compatibility, and public-token lifetime behavior.
- [x] Add observability for key creation, revocation, policy preparation
failures, and public-token mint failures.
- [ ] Exercise create, copy-once display, authenticate, mint, expire,
and revoke flows end to end before broad enablement.
2026-08-06 15:27:10 +01:00

344 lines
11 KiB
TypeScript

import { containerTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import rbacPlugin, { type RoleBaseAccessController } from "@trigger.dev/rbac";
import { expect, vi } from "vitest";
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
import { createEnvironmentApiKey, revokeEnvironmentApiKey } from "~/models/api-key.server";
import type { ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import {
createRuntimeEnvironment,
createTestOrgProjectWithMember,
uniqueId,
} from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
function policyController(
implementation: RoleBaseAccessController["prepareApiKeyPolicy"]
): Pick<RoleBaseAccessController, "prepareApiKeyPolicy"> {
return { prepareApiKeyPolicy: vi.fn(implementation) };
}
function telemetryRecorder(): ApiKeyTelemetry {
return {
recordOperation: vi.fn(),
recordPublicTokenMint: vi.fn(),
};
}
async function setup(prisma: PrismaClient) {
const { organization, project, user } = await createTestOrgProjectWithMember(prisma);
const [environment] = await Promise.all([
createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
slug: uniqueId("prod"),
}),
prisma.organization.update({
where: { id: organization.id },
data: { featureFlags: { [FEATURE_FLAG.additionalApiKeysEnabled]: true } },
}),
prisma.featureFlag.upsert({
where: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled },
create: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled, value: true },
update: { value: true },
}),
]);
return { organization, project, user, environment };
}
containerTest(
"rejects creation when the system-wide issuance gate is disabled",
async ({ prisma }) => {
const { user, environment } = await setup(prisma);
await prisma.featureFlag.update({
where: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled },
data: { value: false },
});
const controller = policyController(async () => ({
ok: true,
policy: { presetId: null, scopes: ["admin"] },
}));
await expect(
createEnvironmentApiKey(
{
environmentId: environment.id,
taskEnvironmentId: environment.id,
userId: user.id,
name: "Disabled",
presetId: "FULL_ACCESS",
},
{ prismaClient: prisma, rbacController: controller }
)
).rejects.toThrow("Creating additional API keys is not enabled");
expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled();
await expect(
prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } })
).resolves.toBe(0);
}
);
containerTest("standalone fallback creates one explicit full-access key", async ({ prisma }) => {
const { user, environment } = await setup(prisma);
const fallback = rbacPlugin.create({ primary: prisma, replica: prisma }, { forceFallback: true });
const telemetry = telemetryRecorder();
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
const result = await createEnvironmentApiKey(
{
environmentId: environment.id,
taskEnvironmentId: environment.id,
userId: user.id,
name: "Full access",
expiresAt,
presetId: "FULL_ACCESS",
},
{ prismaClient: prisma, rbacController: fallback, telemetryRecorder: telemetry }
);
expect(telemetry.recordOperation).toHaveBeenNthCalledWith(1, "prepare_policy", "success");
expect(telemetry.recordOperation).toHaveBeenNthCalledWith(2, "create", "success");
expect(result.plaintext).toMatch(/^tr_prod_sk_[A-Za-z0-9]{24}$/);
expect(result.apiKey).toMatchObject({
presetId: null,
scopes: ["admin"],
expiresAt,
});
await expect(
prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } })
).resolves.toBe(1);
});
containerTest("records successful API key revocation", async ({ prisma }) => {
const { user, environment } = await setup(prisma);
const apiKey = await prisma.apiKey.create({
data: {
name: "Revoke me",
keyHash: uniqueId("hash"),
lastFour: "last",
runtimeEnvironmentId: environment.id,
createdByUserId: user.id,
scopes: ["admin"],
},
});
const telemetry = telemetryRecorder();
await revokeEnvironmentApiKey(
{ environmentId: environment.id, apiKeyId: apiKey.id },
{ prismaClient: prisma, telemetryRecorder: telemetry }
);
expect(telemetry.recordOperation).toHaveBeenCalledWith("revoke", "success");
await expect(prisma.apiKey.findUnique({ where: { id: apiKey.id } })).resolves.toMatchObject({
revokedAt: expect.any(Date),
});
});
containerTest("persists trusted full-access and restricted cloud policies", async ({ prisma }) => {
const { organization, user, environment } = await setup(prisma);
const fullAccessController = policyController(async () => ({
ok: true,
policy: { presetId: "FULL_ACCESS", scopes: ["admin"] },
}));
const restrictedController = policyController(async () => ({
ok: true,
policy: {
presetId: "DEPLOYMENT_READ_ONLY",
scopes: ["read:deployments", "read:tasks"],
},
}));
const fullAccess = await createEnvironmentApiKey(
{
environmentId: environment.id,
taskEnvironmentId: environment.id,
userId: user.id,
name: "Cloud full access",
presetId: "FULL_ACCESS",
},
{ prismaClient: prisma, rbacController: fullAccessController }
);
const restricted = await createEnvironmentApiKey(
{
environmentId: environment.id,
taskEnvironmentId: environment.id,
userId: user.id,
name: "Restricted",
presetId: "DEPLOYMENT_READ_ONLY",
},
{ prismaClient: prisma, rbacController: restrictedController }
);
expect(fullAccess.apiKey).toMatchObject({ presetId: "FULL_ACCESS", scopes: ["admin"] });
expect(restricted.apiKey).toMatchObject({
presetId: "DEPLOYMENT_READ_ONLY",
scopes: ["read:deployments", "read:tasks"],
});
expect(fullAccessController.prepareApiKeyPolicy).toHaveBeenCalledWith({
organizationId: organization.id,
presetId: "FULL_ACCESS",
taskIdentifiers: undefined,
});
});
containerTest("policy preparation failure inserts no credential", async ({ prisma }) => {
const { user, environment } = await setup(prisma);
const controller = policyController(async () => ({
ok: false,
error: "This API key access preset is not available on your plan",
}));
const telemetry = telemetryRecorder();
await expect(
createEnvironmentApiKey(
{
environmentId: environment.id,
taskEnvironmentId: environment.id,
userId: user.id,
name: "Unavailable",
presetId: "RESTRICTED",
},
{ prismaClient: prisma, rbacController: controller, telemetryRecorder: telemetry }
)
).rejects.toThrow("not available on your plan");
expect(telemetry.recordOperation).toHaveBeenCalledWith(
"prepare_policy",
"rejected",
"policy_rejected"
);
await expect(
prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } })
).resolves.toBe(0);
});
containerTest("rejects expired credentials before policy preparation", async ({ prisma }) => {
const { user, environment } = await setup(prisma);
const controller = policyController(async () => ({
ok: true,
policy: { presetId: null, scopes: ["admin"] },
}));
await expect(
createEnvironmentApiKey(
{
environmentId: environment.id,
taskEnvironmentId: environment.id,
userId: user.id,
name: "Already expired",
expiresAt: new Date(Date.now() - 1_000),
presetId: "FULL_ACCESS",
},
{ prismaClient: prisma, rbacController: controller }
)
).rejects.toThrow("Expiration must be in the future");
expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled();
await expect(
prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } })
).resolves.toBe(0);
});
containerTest("rejects too many task identifiers before policy preparation", async ({ prisma }) => {
const { user, environment } = await setup(prisma);
const controller = policyController(async () => ({
ok: true,
policy: { presetId: "TASKS", scopes: ["trigger:tasks"] },
}));
await expect(
createEnvironmentApiKey(
{
environmentId: environment.id,
taskEnvironmentId: environment.id,
userId: user.id,
name: "Too many tasks",
presetId: "TASKS",
taskIdentifiers: Array.from(
{ length: MAX_API_KEY_TASK_IDENTIFIERS + 1 },
(_, index) => `task-${index}`
),
},
{ prismaClient: prisma, rbacController: controller }
)
).rejects.toThrow(`at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks`);
expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled();
});
containerTest("unknown task identifiers insert no credential", async ({ prisma }) => {
const { user, environment } = await setup(prisma);
const controller = policyController(async () => ({
ok: true,
policy: { presetId: "TASKS", scopes: ["trigger:tasks:not-real"] },
}));
await expect(
createEnvironmentApiKey(
{
environmentId: environment.id,
taskEnvironmentId: environment.id,
userId: user.id,
name: "Unknown task",
presetId: "TASKS",
taskIdentifiers: ["not-real"],
},
{ prismaClient: prisma, rbacController: controller }
)
).rejects.toThrow("not available in this environment");
expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled();
await expect(
prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } })
).resolves.toBe(0);
});
containerTest(
"deduplicates task input and persists only the trusted policy",
async ({ prisma }) => {
const { organization, project, user, environment } = await setup(prisma);
await prisma.taskIdentifier.createMany({
data: [
{
runtimeEnvironmentId: environment.id,
projectId: project.id,
slug: "send-email",
},
{
runtimeEnvironmentId: environment.id,
projectId: project.id,
slug: "sync-data",
},
],
});
const trustedScopes = ["trigger:tasks:send-email", "trigger:tasks:sync-data", "read:runs"];
const controller = policyController(async () => ({
ok: true,
policy: { presetId: "TRIGGER_ONLY", scopes: trustedScopes },
}));
const result = await createEnvironmentApiKey(
{
environmentId: environment.id,
taskEnvironmentId: environment.id,
userId: user.id,
name: "Selected tasks",
presetId: "TRIGGER_ONLY",
taskIdentifiers: [" send-email ", "sync-data", "send-email"],
},
{ prismaClient: prisma, rbacController: controller }
);
expect(controller.prepareApiKeyPolicy).toHaveBeenCalledWith({
organizationId: organization.id,
presetId: "TRIGGER_ONLY",
taskIdentifiers: ["send-email", "sync-data"],
});
expect(result.apiKey).toMatchObject({ presetId: "TRIGGER_ONLY", scopes: trustedScopes });
}
);