9409ddf9bc
## 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.
344 lines
11 KiB
TypeScript
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 });
|
|
}
|
|
);
|