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.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Self-hosted deployments can now create multiple full-access API keys for each environment.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Additional environment API keys can now create scoped public access tokens.
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
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"]) {
|
||||
|
||||
@@ -1,67 +1,60 @@
|
||||
import { type RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { type PrismaClient, prisma } from "~/db.server";
|
||||
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 } }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,20 +62,98 @@ export class ApiKeysPresenter {
|
||||
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.findFirstOrThrow({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
+1259
-135
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { handlePublicTokenRequest } from "~/services/publicTokens.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return handlePublicTokenRequest(request);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { resolveAdditionalApiKeyIssuance } from "~/services/additionalApiKeyIssuance";
|
||||
import { FEATURE_FLAG } from "~/v3/featureFlags";
|
||||
|
||||
type IssuancePrismaClient = Pick<PrismaClient, "featureFlag" | "organization">;
|
||||
|
||||
export async function canIssueAdditionalApiKeys(
|
||||
organizationId: string,
|
||||
prismaClient: IssuancePrismaClient = prisma
|
||||
): Promise<boolean> {
|
||||
const [organization, globalFlags] = await Promise.all([
|
||||
prismaClient.organization.findFirst({
|
||||
where: { id: organizationId },
|
||||
select: { featureFlags: true },
|
||||
}),
|
||||
prismaClient.featureFlag.findMany({
|
||||
where: {
|
||||
key: {
|
||||
in: [FEATURE_FLAG.additionalApiKeysEnabled, FEATURE_FLAG.additionalApiKeyIssuanceEnabled],
|
||||
},
|
||||
},
|
||||
select: { key: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveAdditionalApiKeyIssuance(
|
||||
Object.fromEntries(globalFlags.map((featureFlag) => [featureFlag.key, featureFlag.value])),
|
||||
(organization.featureFlags as Record<string, unknown> | null) ?? undefined
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FEATURE_FLAG, type FeatureFlagCatalog } from "~/v3/featureFlags";
|
||||
|
||||
export function resolveAdditionalApiKeyIssuance(
|
||||
globalFlags: Partial<FeatureFlagCatalog> | Record<string, unknown> | undefined,
|
||||
organizationFlags: Record<string, unknown> | undefined
|
||||
): boolean {
|
||||
if (globalFlags?.[FEATURE_FLAG.additionalApiKeyIssuanceEnabled] !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const organizationOverride = organizationFlags?.[FEATURE_FLAG.additionalApiKeysEnabled];
|
||||
if (organizationOverride === true || organizationOverride === false) {
|
||||
return organizationOverride;
|
||||
}
|
||||
|
||||
return globalFlags?.[FEATURE_FLAG.additionalApiKeysEnabled] === true;
|
||||
}
|
||||
@@ -299,7 +299,8 @@ export async function authenticateApiKeyWithScope(
|
||||
action,
|
||||
resource,
|
||||
allowJWT = false,
|
||||
}: { action: string; resource: RbacResource; allowJWT?: boolean }
|
||||
}: { action: string; resource: RbacResource; allowJWT?: boolean },
|
||||
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
|
||||
): Promise<
|
||||
| { ok: true; authentication: ApiAuthenticationResultSuccess }
|
||||
| { ok: false; status: 401 | 403; error: string }
|
||||
@@ -309,11 +310,7 @@ export async function authenticateApiKeyWithScope(
|
||||
return { ok: false, status: 401, error: "Invalid or Missing API key" };
|
||||
}
|
||||
|
||||
const result = await authenticateAuthorizeBearerWithTelemetry(
|
||||
request,
|
||||
{ action, resource },
|
||||
{ allowJWT }
|
||||
);
|
||||
const result = await authorizeBearer(request, { action, resource }, { allowJWT });
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FULL_ACCESS_PRESET_ID } from "@trigger.dev/rbac";
|
||||
|
||||
export type ApiKeyPreset = {
|
||||
id: string;
|
||||
available: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
scopes?: string[];
|
||||
usesTaskSelection?: boolean;
|
||||
};
|
||||
|
||||
export function validateCreateApiKeyPreset({
|
||||
presets,
|
||||
presetId,
|
||||
taskScope,
|
||||
taskIdentifiers,
|
||||
hasTaskParameters,
|
||||
}: {
|
||||
presets: ApiKeyPreset[] | null;
|
||||
presetId?: string;
|
||||
taskScope?: "all" | "selected";
|
||||
taskIdentifiers: string[];
|
||||
hasTaskParameters: boolean;
|
||||
}): { presetId: string; usesTaskSelection: boolean } {
|
||||
const fullAccess = { presetId: FULL_ACCESS_PRESET_ID, usesTaskSelection: false };
|
||||
|
||||
if (presets === null) {
|
||||
if (presetId !== FULL_ACCESS_PRESET_ID || hasTaskParameters) {
|
||||
throw new Error("API key access presets are not available");
|
||||
}
|
||||
return fullAccess;
|
||||
}
|
||||
|
||||
if (!presetId) {
|
||||
throw new Error("A preset is required");
|
||||
}
|
||||
|
||||
const preset = presets.find((candidate) => candidate.id === presetId);
|
||||
if (!preset) {
|
||||
throw new Error("Invalid API key access preset");
|
||||
}
|
||||
if (!preset.available) {
|
||||
throw new Error("This API key access preset is not available on your plan");
|
||||
}
|
||||
|
||||
if (!preset.usesTaskSelection && hasTaskParameters) {
|
||||
throw new Error("This API key access preset does not support task selection");
|
||||
}
|
||||
if (preset.usesTaskSelection && taskScope === "selected" && taskIdentifiers.length === 0) {
|
||||
throw new Error("Select at least one task");
|
||||
}
|
||||
if (preset.usesTaskSelection && taskScope !== "selected" && taskIdentifiers.length > 0) {
|
||||
throw new Error("Task identifiers require selected task scope");
|
||||
}
|
||||
|
||||
return { presetId: preset.id, usesTaskSelection: preset.usesTaskSelection ?? false };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getMeter } from "@internal/tracing";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
export type ApiKeyOperation = "create" | "prepare_policy" | "revoke";
|
||||
export type ApiKeyOperationResult = "success" | "rejected" | "error";
|
||||
export type ApiKeyOperationReason =
|
||||
| "none"
|
||||
| "database_error"
|
||||
| "not_found_or_revoked"
|
||||
| "policy_rejected"
|
||||
| "policy_error";
|
||||
|
||||
export type PublicTokenMintResult = "success" | "rejected" | "error";
|
||||
export type PublicTokenMintReason =
|
||||
| "none"
|
||||
| "invalid_body"
|
||||
| "scope_not_allowed"
|
||||
| "invalid_expiration"
|
||||
| "expiration_not_future"
|
||||
| "expiration_too_long"
|
||||
| "signing_failed";
|
||||
|
||||
const telemetry = singleton("apiKeyTelemetry", () => {
|
||||
const meter = getMeter("api-key");
|
||||
|
||||
return {
|
||||
operations: meter.createCounter("api_key.operations", {
|
||||
description: "Additional environment API key management operations",
|
||||
}),
|
||||
publicTokenMintAttempts: meter.createCounter("public_token.mint_attempts", {
|
||||
description: "Public access token mint attempts using environment API keys",
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export const apiKeyTelemetry = {
|
||||
recordOperation(
|
||||
operation: ApiKeyOperation,
|
||||
result: ApiKeyOperationResult,
|
||||
reason: ApiKeyOperationReason = "none"
|
||||
) {
|
||||
telemetry.operations.add(1, { operation, result, reason });
|
||||
},
|
||||
recordPublicTokenMint(result: PublicTokenMintResult, reason: PublicTokenMintReason = "none") {
|
||||
telemetry.publicTokenMintAttempts.add(1, { result, reason });
|
||||
},
|
||||
};
|
||||
|
||||
export type ApiKeyTelemetry = typeof apiKeyTelemetry;
|
||||
@@ -32,12 +32,18 @@ export function presentedApiKeyFromAuthentication(
|
||||
* Keep PAT/OAT authentication on the legacy path while routing machine API
|
||||
* keys through the RBAC controller, where plugin grants are applied.
|
||||
*/
|
||||
type AuthenticationDependencies = {
|
||||
authenticateRequest: typeof authenticateRequest;
|
||||
authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope;
|
||||
};
|
||||
|
||||
export async function authenticateEnvironmentScopedApiRequest(
|
||||
request: Request,
|
||||
action: "read" | "write",
|
||||
resource: EnvironmentScopedResource
|
||||
resource: EnvironmentScopedResource,
|
||||
dependencies: AuthenticationDependencies = { authenticateRequest, authenticateApiKeyWithScope }
|
||||
): Promise<EnvironmentScopedAuthentication> {
|
||||
const userOrOrganizationAuthentication = await authenticateRequest(request, {
|
||||
const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, {
|
||||
personalAccessToken: true,
|
||||
organizationAccessToken: true,
|
||||
apiKey: false,
|
||||
@@ -46,7 +52,7 @@ export async function authenticateEnvironmentScopedApiRequest(
|
||||
return { ok: true, authentication: userOrOrganizationAuthentication };
|
||||
}
|
||||
|
||||
const apiKeyAuthentication = await authenticateApiKeyWithScope(request, {
|
||||
const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, {
|
||||
action,
|
||||
resource: { type: resource },
|
||||
});
|
||||
@@ -63,9 +69,10 @@ export async function authenticateEnvironmentScopedApiRequest(
|
||||
/** Env var API routes: PAT/OAT on the legacy path, machine keys via RBAC. */
|
||||
export function authenticateEnvVarApiRequest(
|
||||
request: Request,
|
||||
action: "read" | "write"
|
||||
action: "read" | "write",
|
||||
dependencies?: AuthenticationDependencies
|
||||
): Promise<EnvironmentScopedAuthentication> {
|
||||
return authenticateEnvironmentScopedApiRequest(request, action, "envvars");
|
||||
return authenticateEnvironmentScopedApiRequest(request, action, "envvars", dependencies);
|
||||
}
|
||||
|
||||
const RESOURCE_LABELS: Record<EnvironmentScopedResource, string> = {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { generateJWT } from "@trigger.dev/core/v3/jwt";
|
||||
import type { RoleBaseAccessController } from "@trigger.dev/rbac";
|
||||
import { resolveJwtSigningKey, scopesWithinAbility } from "@trigger.dev/rbac";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
|
||||
// Public access tokens may be valid for at most 30 days.
|
||||
export const MAX_PUBLIC_TOKEN_LIFETIME_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
const RequestBodySchema = z.object({
|
||||
scopes: z.array(z.string()).min(1),
|
||||
expirationTime: z.union([z.string(), z.number()]).optional(),
|
||||
oneTimeUse: z.boolean().optional(),
|
||||
realtime: z
|
||||
.object({
|
||||
skipColumns: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const RELATIVE_TIME_PATTERN =
|
||||
/^(\+|-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i;
|
||||
|
||||
function expirationTimestamp(expirationTime: string | number, now: number): number | undefined {
|
||||
if (typeof expirationTime === "number") {
|
||||
return expirationTime;
|
||||
}
|
||||
|
||||
const match = RELATIVE_TIME_PATTERN.exec(expirationTime);
|
||||
if (!match || (match[4] && match[1])) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = Number.parseFloat(match[2]!);
|
||||
const unit = match[3]!.toLowerCase();
|
||||
const unitSeconds = unit.startsWith("s")
|
||||
? 1
|
||||
: unit.startsWith("m")
|
||||
? 60
|
||||
: unit.startsWith("h")
|
||||
? 60 * 60
|
||||
: unit.startsWith("d")
|
||||
? 24 * 60 * 60
|
||||
: unit.startsWith("w")
|
||||
? 7 * 24 * 60 * 60
|
||||
: 365.25 * 24 * 60 * 60;
|
||||
const relativeSeconds = Math.round(value * unitSeconds);
|
||||
const isPast = match[1] === "-" || match[4]?.toLowerCase() === "ago";
|
||||
|
||||
return now + (isPast ? -relativeSeconds : relativeSeconds);
|
||||
}
|
||||
|
||||
export async function handlePublicTokenRequest(
|
||||
request: Request,
|
||||
controller: Pick<RoleBaseAccessController, "authenticateBearer"> = rbac,
|
||||
telemetryRecorder: ApiKeyTelemetry = apiKeyTelemetry
|
||||
) {
|
||||
// Public JWTs are intentionally not enabled here. Only API keys may mint tokens.
|
||||
const authResult = await controller.authenticateBearer(request);
|
||||
if (!authResult.ok) {
|
||||
return json({ error: authResult.error }, { status: authResult.status });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
telemetryRecorder.recordPublicTokenMint("rejected", "invalid_body");
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsedBody = RequestBodySchema.safeParse(body);
|
||||
if (!parsedBody.success) {
|
||||
telemetryRecorder.recordPublicTokenMint("rejected", "invalid_body");
|
||||
return json(
|
||||
{ error: "Invalid request body", issues: parsedBody.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const scopeCheck = scopesWithinAbility(parsedBody.data.scopes, authResult.ability);
|
||||
if (!scopeCheck.ok) {
|
||||
telemetryRecorder.recordPublicTokenMint("rejected", "scope_not_allowed");
|
||||
return json(
|
||||
{
|
||||
error: "Requested scopes exceed the API key's access",
|
||||
code: "scopes_exceed_key_access",
|
||||
deniedScopes: scopeCheck.deniedScopes,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const expirationTime = parsedBody.data.expirationTime ?? "15m";
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresAt = expirationTimestamp(expirationTime, now);
|
||||
if (expiresAt === undefined) {
|
||||
telemetryRecorder.recordPublicTokenMint("rejected", "invalid_expiration");
|
||||
return json({ error: "Invalid expiration time" }, { status: 400 });
|
||||
}
|
||||
// `expirationTimestamp` accepts past values ("-5m", "5m ago"), which would
|
||||
// otherwise mint an already-expired token behind a 200.
|
||||
if (expiresAt <= now) {
|
||||
telemetryRecorder.recordPublicTokenMint("rejected", "expiration_not_future");
|
||||
return json({ error: "Expiration time must be in the future" }, { status: 400 });
|
||||
}
|
||||
if (expiresAt - now > MAX_PUBLIC_TOKEN_LIFETIME_SECONDS) {
|
||||
telemetryRecorder.recordPublicTokenMint("rejected", "expiration_too_long");
|
||||
return json({ error: "Expiration time cannot exceed 30 days" }, { status: 400 });
|
||||
}
|
||||
|
||||
let token: string;
|
||||
try {
|
||||
token = await generateJWT({
|
||||
secretKey: resolveJwtSigningKey(authResult.environment),
|
||||
payload: {
|
||||
sub: authResult.environment.id,
|
||||
pub: true,
|
||||
scopes: parsedBody.data.scopes,
|
||||
...(parsedBody.data.oneTimeUse ? { otu: true } : {}),
|
||||
...(parsedBody.data.realtime ? { realtime: parsedBody.data.realtime } : {}),
|
||||
},
|
||||
// Pass the absolute `exp` validated above, not the original string.
|
||||
// `generateJWT` hands the string to jose's own parser, which would leave
|
||||
// the 30-day cap enforced against a different computation than the one
|
||||
// that actually sets the claim.
|
||||
expirationTime: expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
telemetryRecorder.recordPublicTokenMint("error", "signing_failed");
|
||||
throw error;
|
||||
}
|
||||
|
||||
telemetryRecorder.recordPublicTokenMint("success");
|
||||
return json({ token });
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export const FEATURE_FLAG = {
|
||||
runOpsMintKindPrev: "runOpsMintKindPrev",
|
||||
runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt",
|
||||
queueMetricsUiEnabled: "queueMetricsUiEnabled",
|
||||
// Per-organization rollout for creating additional environment API keys.
|
||||
additionalApiKeysEnabled: "additionalApiKeysEnabled",
|
||||
// System-wide kill switch for issuing additional environment API keys.
|
||||
additionalApiKeyIssuanceEnabled: "additionalApiKeyIssuanceEnabled",
|
||||
// System-wide kill switch for additional (scoped) environment API-key lookup.
|
||||
// Defaults off; enable during rollout once the new lookup path is trusted.
|
||||
additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled",
|
||||
@@ -78,9 +82,10 @@ export const FeatureFlagCatalog = {
|
||||
// Per-org access to the Queue Metrics dashboard UI (view only; emission is global and
|
||||
// separate). Off unless enabled for the org.
|
||||
[FEATURE_FLAG.queueMetricsUiEnabled]: z.coerce.boolean(),
|
||||
// Strict z.boolean() (not z.coerce.boolean()): coercion turns the string
|
||||
// "false" into true, which would silently enable this kill switch the wrong
|
||||
// way if written as a string. Cold/absent resolves to the safe `false`.
|
||||
// Strict booleans prevent a stringified "false" from silently enabling API-key
|
||||
// creation or lookup. Cold/absent values resolve to the safe `false`.
|
||||
[FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(),
|
||||
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: z.boolean(),
|
||||
[FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(),
|
||||
};
|
||||
|
||||
@@ -100,7 +105,8 @@ export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [
|
||||
FEATURE_FLAG.taskEventRepository,
|
||||
FEATURE_FLAG.runOpsMintKindPrev,
|
||||
FEATURE_FLAG.runOpsMintKindFlippedAt,
|
||||
// System-wide only — an org must not be able to override the rollout switch.
|
||||
// System-wide only — orgs must not be able to override these kill switches.
|
||||
FEATURE_FLAG.additionalApiKeyIssuanceEnabled,
|
||||
FEATURE_FLAG.additionalApiKeyLookupEnabled,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAdditionalApiKeyIssuance } from "~/services/additionalApiKeyIssuance";
|
||||
import { FEATURE_FLAG, FeatureFlagCatalog, ORG_LOCKED_FLAGS } from "~/v3/featureFlags";
|
||||
|
||||
describe("additional API key issuance controls", () => {
|
||||
it("registers strict rollout and system-wide flags", () => {
|
||||
expect(
|
||||
FeatureFlagCatalog[FEATURE_FLAG.additionalApiKeysEnabled].safeParse("false").success
|
||||
).toBe(false);
|
||||
expect(
|
||||
FeatureFlagCatalog[FEATURE_FLAG.additionalApiKeyIssuanceEnabled].safeParse("false").success
|
||||
).toBe(false);
|
||||
expect(ORG_LOCKED_FLAGS).not.toContain(FEATURE_FLAG.additionalApiKeysEnabled);
|
||||
expect(ORG_LOCKED_FLAGS).toContain(FEATURE_FLAG.additionalApiKeyIssuanceEnabled);
|
||||
});
|
||||
|
||||
it("defaults to disabled", () => {
|
||||
expect(resolveAdditionalApiKeyIssuance(undefined, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the system-wide issuance gate", () => {
|
||||
expect(
|
||||
resolveAdditionalApiKeyIssuance(
|
||||
{ [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: false },
|
||||
{ [FEATURE_FLAG.additionalApiKeysEnabled]: true }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows an organization override when issuance is enabled", () => {
|
||||
expect(
|
||||
resolveAdditionalApiKeyIssuance(
|
||||
{ [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true },
|
||||
{ [FEATURE_FLAG.additionalApiKeysEnabled]: true }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the global rollout value when the organization has no override", () => {
|
||||
expect(
|
||||
resolveAdditionalApiKeyIssuance(
|
||||
{
|
||||
[FEATURE_FLAG.additionalApiKeysEnabled]: true,
|
||||
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true,
|
||||
},
|
||||
undefined
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an organization to opt out of a global rollout", () => {
|
||||
expect(
|
||||
resolveAdditionalApiKeyIssuance(
|
||||
{
|
||||
[FEATURE_FLAG.additionalApiKeysEnabled]: true,
|
||||
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true,
|
||||
},
|
||||
{ [FEATURE_FLAG.additionalApiKeysEnabled]: false }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,124 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const rbacMocks = vi.hoisted(() => ({
|
||||
authenticateBearer: vi.fn<(...args: any[]) => Promise<any>>(),
|
||||
}));
|
||||
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
attemptsAdd: vi.fn(),
|
||||
durationRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@internal/tracing", () => ({
|
||||
getMeter: () => ({
|
||||
createCounter: () => ({ add: telemetryMocks.attemptsAdd }),
|
||||
createHistogram: () => ({ record: telemetryMocks.durationRecord }),
|
||||
createObservableGauge: () => ({ addCallback: vi.fn() }),
|
||||
}),
|
||||
}));
|
||||
vi.mock("~/services/rbac.server", () => ({ rbac: rbacMocks }));
|
||||
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
|
||||
vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
|
||||
vi.mock("~/models/project.server", () => ({ findProjectByRef: vi.fn() }));
|
||||
vi.mock("~/models/runtimeEnvironment.server", () => ({
|
||||
authIncludeBase: {},
|
||||
authIncludeWithParent: {},
|
||||
findEnvironmentByApiKey: vi.fn(),
|
||||
findEnvironmentByPublicApiKey: vi.fn(),
|
||||
toAuthenticated: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/personalAccessToken.server", () => ({
|
||||
authenticateApiRequestWithPersonalAccessToken: vi.fn(),
|
||||
isPersonalAccessToken: () => false,
|
||||
}));
|
||||
vi.mock("~/services/organizationAccessToken.server", () => ({
|
||||
authenticateApiRequestWithOrganizationAccessToken: vi.fn(),
|
||||
isOrganizationAccessToken: () => false,
|
||||
}));
|
||||
vi.mock("~/services/realtime/jwtAuth.server", () => ({
|
||||
isPublicJWT: () => false,
|
||||
validatePublicJwtKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/logger.server", () => ({
|
||||
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
|
||||
|
||||
describe("authenticateApiKeyWithScope", () => {
|
||||
beforeEach(() => {
|
||||
rbacMocks.authenticateBearer.mockReset();
|
||||
telemetryMocks.attemptsAdd.mockReset();
|
||||
telemetryMocks.durationRecord.mockReset();
|
||||
});
|
||||
const authorizeBearer = vi.fn();
|
||||
|
||||
describe("authenticateApiKeyWithScope", () => {
|
||||
it("returns 401 without a bearer credential", async () => {
|
||||
const result = await authenticateApiKeyWithScope(new Request("https://example.com"), {
|
||||
action: "read",
|
||||
resource: { type: "envvars" },
|
||||
});
|
||||
const result = await authenticateApiKeyWithScope(
|
||||
new Request("https://example.com"),
|
||||
{ action: "read", resource: { type: "envvars" } },
|
||||
authorizeBearer
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: 401,
|
||||
error: "Invalid or Missing API key",
|
||||
});
|
||||
expect(rbacMocks.authenticateBearer).not.toHaveBeenCalled();
|
||||
expect(authorizeBearer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 401 as const, error: "Invalid API key" },
|
||||
{ status: 403 as const, error: "Unauthorized" },
|
||||
])("preserves controller $status failures", async (failure) => {
|
||||
rbacMocks.authenticateBearer.mockResolvedValue({ ok: false, ...failure });
|
||||
authorizeBearer.mockResolvedValueOnce({ ok: false, ...failure });
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { Authorization: "Bearer tr_test_key" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
authenticateApiKeyWithScope(request, {
|
||||
action: "write",
|
||||
resource: { type: "deployments" },
|
||||
})
|
||||
authenticateApiKeyWithScope(
|
||||
request,
|
||||
{ action: "write", resource: { type: "deployments" } },
|
||||
authorizeBearer
|
||||
)
|
||||
).resolves.toEqual({ ok: false, ...failure });
|
||||
});
|
||||
|
||||
it("bridges controller success into the legacy private authentication shape", async () => {
|
||||
const environment = { id: "env_123" };
|
||||
const ability = { can: vi.fn(() => true), canSuper: vi.fn(() => true) };
|
||||
rbacMocks.authenticateBearer.mockResolvedValue({
|
||||
authorizeBearer.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
environment,
|
||||
ability,
|
||||
subject: { type: "apiKey", apiKeyId: "key_123" },
|
||||
resolution: { credentialKind: "root_api_key", lookupPath: "root_current" },
|
||||
});
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { Authorization: "Bearer tr_test_key", "x-trigger-branch": "feature/test" },
|
||||
headers: { Authorization: "Bearer tr_test_key" },
|
||||
});
|
||||
|
||||
const result = await authenticateApiKeyWithScope(request, {
|
||||
action: "read",
|
||||
resource: { type: "envvars" },
|
||||
allowJWT: true,
|
||||
});
|
||||
const result = await authenticateApiKeyWithScope(
|
||||
request,
|
||||
{ action: "read", resource: { type: "envvars" }, allowJWT: true },
|
||||
authorizeBearer
|
||||
);
|
||||
|
||||
expect(rbacMocks.authenticateBearer).toHaveBeenCalledWith(request, { allowJWT: true });
|
||||
expect(ability.can).toHaveBeenCalledWith("read", { type: "envvars" });
|
||||
expect(telemetryMocks.attemptsAdd).toHaveBeenCalledWith(1, {
|
||||
resolver: "rbac",
|
||||
credential_kind: "root_api_key",
|
||||
result: "success",
|
||||
lookup_path: "root_current",
|
||||
});
|
||||
expect(telemetryMocks.durationRecord).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
resolver: "rbac",
|
||||
credential_kind: "root_api_key",
|
||||
result: "success",
|
||||
lookup_path: "root_current",
|
||||
})
|
||||
expect(authorizeBearer).toHaveBeenCalledWith(
|
||||
request,
|
||||
{ action: "read", resource: { type: "envvars" } },
|
||||
{ allowJWT: true }
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
@@ -132,32 +73,24 @@ describe("authenticateApiKeyWithScope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("records successful authentication before returning an authorization failure", async () => {
|
||||
it("returns authorization failures from the controller", async () => {
|
||||
const ability = { can: vi.fn(() => false), canSuper: vi.fn(() => false) };
|
||||
rbacMocks.authenticateBearer.mockResolvedValue({
|
||||
ok: true,
|
||||
environment: { id: "env_123" },
|
||||
ability,
|
||||
subject: { type: "apiKey", apiKeyId: "key_123" },
|
||||
resolution: { credentialKind: "additional_api_key", lookupPath: "additional" },
|
||||
authorizeBearer.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: "Unauthorized",
|
||||
});
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { Authorization: "Bearer tr_prod_sk_0123456789abcdefghijklmn" },
|
||||
headers: { Authorization: "Bearer tr_prod_sk_test" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
authenticateApiKeyWithScope(request, {
|
||||
action: "write",
|
||||
resource: { type: "deployments" },
|
||||
})
|
||||
authenticateApiKeyWithScope(
|
||||
request,
|
||||
{ action: "write", resource: { type: "deployments" } },
|
||||
authorizeBearer
|
||||
)
|
||||
).resolves.toEqual({ ok: false, status: 403, error: "Unauthorized" });
|
||||
|
||||
expect(ability.can).toHaveBeenCalledWith("write", { type: "deployments" });
|
||||
expect(telemetryMocks.attemptsAdd).toHaveBeenCalledWith(1, {
|
||||
resolver: "rbac",
|
||||
credential_kind: "additional_api_key",
|
||||
result: "success",
|
||||
lookup_path: "additional",
|
||||
});
|
||||
expect(ability.can).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { containerTest } from "@internal/testcontainers";
|
||||
import { expect, vi } from "vitest";
|
||||
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
|
||||
import {
|
||||
createRuntimeEnvironment,
|
||||
createTestOrgProjectWithMember,
|
||||
createTestUser,
|
||||
uniqueId,
|
||||
} from "./fixtures/environmentVariablesFixtures";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
containerTest("binds API key reads to the organization in the route", async ({ prisma }) => {
|
||||
const first = await createTestOrgProjectWithMember(prisma);
|
||||
const second = await createTestOrgProjectWithMember(prisma, { userId: first.user.id });
|
||||
const environment = await createRuntimeEnvironment(prisma, {
|
||||
projectId: second.project.id,
|
||||
organizationId: second.organization.id,
|
||||
type: "PRODUCTION",
|
||||
slug: uniqueId("prod"),
|
||||
});
|
||||
const presenter = new ApiKeysPresenter(prisma);
|
||||
|
||||
await expect(
|
||||
presenter.call({
|
||||
userId: first.user.id,
|
||||
organizationSlug: first.organization.slug,
|
||||
projectSlug: second.project.slug,
|
||||
environmentSlug: environment.slug,
|
||||
})
|
||||
).rejects.toThrow("Environment not found");
|
||||
});
|
||||
|
||||
containerTest(
|
||||
"describes stored full, catalogued, and unknown policies without exposing scopes",
|
||||
async ({ prisma }) => {
|
||||
const { organization, project, user } = await createTestOrgProjectWithMember(prisma);
|
||||
const environment = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
slug: uniqueId("prod"),
|
||||
});
|
||||
await prisma.apiKey.create({
|
||||
data: {
|
||||
name: "Full access",
|
||||
keyHash: uniqueId("full-hash"),
|
||||
lastFour: "full",
|
||||
runtimeEnvironmentId: environment.id,
|
||||
createdByUserId: user.id,
|
||||
presetId: null,
|
||||
scopes: ["admin"],
|
||||
},
|
||||
});
|
||||
await prisma.apiKey.create({
|
||||
data: {
|
||||
name: "Restricted access",
|
||||
keyHash: uniqueId("restricted-hash"),
|
||||
lastFour: "rstr",
|
||||
runtimeEnvironmentId: environment.id,
|
||||
createdByUserId: user.id,
|
||||
presetId: "RESTRICTED_TEST_PRESET",
|
||||
scopes: ["read:deployments"],
|
||||
},
|
||||
});
|
||||
await prisma.apiKey.create({
|
||||
data: {
|
||||
name: "Unknown preset",
|
||||
keyHash: uniqueId("unknown-hash"),
|
||||
lastFour: "unkn",
|
||||
runtimeEnvironmentId: environment.id,
|
||||
createdByUserId: user.id,
|
||||
presetId: "REMOVED_PRESET",
|
||||
scopes: ["trigger:tasks:send-email"],
|
||||
},
|
||||
});
|
||||
await prisma.apiKey.create({
|
||||
data: {
|
||||
name: "Restricted without preset",
|
||||
keyHash: uniqueId("null-restricted-hash"),
|
||||
lastFour: "null",
|
||||
runtimeEnvironmentId: environment.id,
|
||||
createdByUserId: user.id,
|
||||
presetId: null,
|
||||
scopes: ["read:runs"],
|
||||
},
|
||||
});
|
||||
await prisma.apiKey.create({
|
||||
data: {
|
||||
name: "Revoked key",
|
||||
keyHash: uniqueId("revoked-hash"),
|
||||
lastFour: "rvkd",
|
||||
runtimeEnvironmentId: environment.id,
|
||||
createdByUserId: user.id,
|
||||
presetId: null,
|
||||
scopes: ["admin"],
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const describeApiKeyPolicy = vi.fn(async (policy: { scopes: string[] }) =>
|
||||
policy.scopes.includes("trigger:tasks:send-email") ? { taskIdentifiers: ["send-email"] } : {}
|
||||
);
|
||||
const apiKeyPresets = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "RESTRICTED_TEST_PRESET",
|
||||
label: "Restricted access",
|
||||
description: "Restricted test access",
|
||||
scopes: ["read:deployments"],
|
||||
usesTaskSelection: false,
|
||||
available: true,
|
||||
},
|
||||
]);
|
||||
const presenter = new ApiKeysPresenter(prisma, { describeApiKeyPolicy, apiKeyPresets });
|
||||
|
||||
const result = await presenter.call({
|
||||
userId: user.id,
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
environmentSlug: environment.slug,
|
||||
});
|
||||
const keysByName = new Map(result.apiKeys.map((key) => [key.name, key]));
|
||||
|
||||
expect(describeApiKeyPolicy).toHaveBeenCalledTimes(4);
|
||||
expect(describeApiKeyPolicy).toHaveBeenCalledWith({
|
||||
presetId: null,
|
||||
scopes: ["admin"],
|
||||
});
|
||||
expect(apiKeyPresets).toHaveBeenCalledWith(organization.id);
|
||||
expect(result.rootApiKey.obfuscated).toBe(`tr_prod_••••••••${environment.apiKey.slice(-4)}`);
|
||||
expect(keysByName.get("Full access")?.obfuscated).toBe("tr_prod_sk_••••••••full");
|
||||
expect(keysByName.get("Full access")?.access).toMatchObject({
|
||||
presetId: null,
|
||||
label: "Full access",
|
||||
usesTaskSelection: false,
|
||||
});
|
||||
expect(keysByName.get("Restricted access")?.access).toMatchObject({
|
||||
presetId: "RESTRICTED_TEST_PRESET",
|
||||
label: "Restricted access",
|
||||
usesTaskSelection: false,
|
||||
});
|
||||
expect(keysByName.get("Restricted without preset")?.access).toMatchObject({
|
||||
presetId: null,
|
||||
label: "Custom",
|
||||
usesTaskSelection: false,
|
||||
});
|
||||
expect(keysByName.get("Unknown preset")?.access).toEqual({
|
||||
presetId: "REMOVED_PRESET",
|
||||
label: "Custom",
|
||||
taskIdentifiers: ["send-email"],
|
||||
usesTaskSelection: true,
|
||||
});
|
||||
expect(keysByName.get("Full access")).not.toHaveProperty("scopes");
|
||||
expect(keysByName.get("Restricted access")).not.toHaveProperty("scopes");
|
||||
expect(keysByName.has("Revoked key")).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"does not expose another member's named development branch keys",
|
||||
async ({ prisma }) => {
|
||||
const owner = await createTestOrgProjectWithMember(prisma);
|
||||
const otherUser = await createTestUser(prisma);
|
||||
const otherMember = await prisma.orgMember.create({
|
||||
data: {
|
||||
organizationId: owner.organization.id,
|
||||
userId: otherUser.id,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
const otherRoot = await createRuntimeEnvironment(prisma, {
|
||||
projectId: owner.project.id,
|
||||
organizationId: owner.organization.id,
|
||||
type: "DEVELOPMENT",
|
||||
orgMemberId: otherMember.id,
|
||||
slug: uniqueId("other-dev"),
|
||||
});
|
||||
const branch = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: uniqueId("named-branch"),
|
||||
type: "DEVELOPMENT",
|
||||
projectId: owner.project.id,
|
||||
organizationId: owner.organization.id,
|
||||
orgMemberId: otherMember.id,
|
||||
parentEnvironmentId: otherRoot.id,
|
||||
branchName: "feature/secret",
|
||||
apiKey: uniqueId("api"),
|
||||
pkApiKey: uniqueId("pk"),
|
||||
shortcode: uniqueId("sc"),
|
||||
},
|
||||
});
|
||||
const presenter = new ApiKeysPresenter(prisma);
|
||||
|
||||
await expect(
|
||||
presenter.call({
|
||||
userId: owner.user.id,
|
||||
organizationSlug: owner.organization.slug,
|
||||
projectSlug: owner.project.slug,
|
||||
environmentSlug: branch.slug,
|
||||
})
|
||||
).rejects.toThrow("Environment not found");
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,343 @@
|
||||
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 });
|
||||
}
|
||||
);
|
||||
@@ -1,20 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
authenticateRequest: vi.fn<(...args: any[]) => Promise<any>>(),
|
||||
authenticateApiKeyWithScope: vi.fn<(...args: any[]) => Promise<any>>(),
|
||||
}));
|
||||
|
||||
vi.mock("~/services/apiAuth.server", () => authMocks);
|
||||
vi.mock("~/services/rbac.server", () => ({
|
||||
rbac: { authenticatePat: vi.fn(), authenticateUserActor: vi.fn() },
|
||||
}));
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
authenticateEnvVarApiRequest,
|
||||
presentedApiKeyFromAuthentication,
|
||||
} from "~/services/environmentVariableApiAccess.server";
|
||||
|
||||
const authenticateRequest = vi.fn();
|
||||
const authenticateApiKeyWithScope = vi.fn();
|
||||
const dependencies = { authenticateRequest, authenticateApiKeyWithScope };
|
||||
|
||||
describe("presentedApiKeyFromAuthentication", () => {
|
||||
it("returns the API key that authenticated the request", () => {
|
||||
expect(
|
||||
@@ -24,7 +17,7 @@ describe("presentedApiKeyFromAuthentication", () => {
|
||||
ok: true,
|
||||
apiKey: "tr_prod_sk_presented",
|
||||
type: "PRIVATE",
|
||||
environment: {} as never,
|
||||
environment: {},
|
||||
},
|
||||
})
|
||||
).toBe("tr_prod_sk_presented");
|
||||
@@ -34,78 +27,52 @@ describe("presentedApiKeyFromAuthentication", () => {
|
||||
expect(
|
||||
presentedApiKeyFromAuthentication({
|
||||
type: "personalAccessToken",
|
||||
result: { userId: "user_123" } as never,
|
||||
result: { userId: "user_123" },
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticateEnvVarApiRequest", () => {
|
||||
beforeEach(() => {
|
||||
authMocks.authenticateRequest.mockReset();
|
||||
authMocks.authenticateApiKeyWithScope.mockReset();
|
||||
it("keeps PAT authentication on the legacy path", async () => {
|
||||
const authentication = { type: "personalAccessToken", result: { userId: "user_123" } } as never;
|
||||
authenticateRequest.mockResolvedValueOnce(authentication);
|
||||
|
||||
await expect(
|
||||
authenticateEnvVarApiRequest(new Request("https://example.com"), "read", dependencies)
|
||||
).resolves.toEqual({ ok: true, authentication });
|
||||
expect(authenticateApiKeyWithScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: "personalAccessToken", result: { userId: "user_123" } },
|
||||
{ type: "organizationAccessToken", result: { organizationId: "org_123" } },
|
||||
])("preserves $type authentication", async (authentication) => {
|
||||
authMocks.authenticateRequest.mockResolvedValue(authentication);
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
|
||||
await expect(authenticateEnvVarApiRequest(request, "read")).resolves.toEqual({
|
||||
ok: true,
|
||||
authentication,
|
||||
});
|
||||
expect(authMocks.authenticateRequest).toHaveBeenCalledWith(request, {
|
||||
personalAccessToken: true,
|
||||
organizationAccessToken: true,
|
||||
apiKey: false,
|
||||
});
|
||||
expect(authMocks.authenticateApiKeyWithScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes API-key credentials through scoped controller authentication", async () => {
|
||||
it("uses scoped API-key authentication when no PAT is present", async () => {
|
||||
authenticateRequest.mockResolvedValueOnce(undefined);
|
||||
const authentication = {
|
||||
ok: true,
|
||||
apiKey: "tr_test_key",
|
||||
apiKey: "tr_prod_sk_presented",
|
||||
type: "PRIVATE",
|
||||
environment: { id: "env_123" },
|
||||
ability: { can: vi.fn(() => true) },
|
||||
environment: {},
|
||||
};
|
||||
authMocks.authenticateRequest.mockResolvedValue(undefined);
|
||||
authMocks.authenticateApiKeyWithScope.mockResolvedValue({ ok: true, authentication });
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { Authorization: "Bearer tr_test_key" },
|
||||
});
|
||||
authenticateApiKeyWithScope.mockResolvedValueOnce({ ok: true, authentication });
|
||||
|
||||
await expect(authenticateEnvVarApiRequest(request, "write")).resolves.toEqual({
|
||||
ok: true,
|
||||
authentication: { type: "apiKey", result: authentication },
|
||||
});
|
||||
expect(authMocks.authenticateApiKeyWithScope).toHaveBeenCalledWith(request, {
|
||||
await expect(
|
||||
authenticateEnvVarApiRequest(new Request("https://example.com"), "write", dependencies)
|
||||
).resolves.toEqual({ ok: true, authentication: { type: "apiKey", result: authentication } });
|
||||
expect(authenticateApiKeyWithScope).toHaveBeenCalledWith(expect.any(Request), {
|
||||
action: "write",
|
||||
resource: { type: "envvars" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves scoped controller failures", async () => {
|
||||
authMocks.authenticateRequest.mockResolvedValue(undefined);
|
||||
authMocks.authenticateApiKeyWithScope.mockResolvedValue({
|
||||
it("preserves scoped API-key failures", async () => {
|
||||
authenticateRequest.mockResolvedValueOnce(undefined);
|
||||
authenticateApiKeyWithScope.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: "Unauthorized",
|
||||
});
|
||||
|
||||
await expect(
|
||||
authenticateEnvVarApiRequest(
|
||||
new Request("https://example.com", {
|
||||
headers: { Authorization: "Bearer tr_test_key" },
|
||||
}),
|
||||
"read"
|
||||
)
|
||||
authenticateEnvVarApiRequest(new Request("https://example.com"), "read", dependencies)
|
||||
).resolves.toEqual({ ok: false, status: 403, error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { postgresTest } from "@internal/testcontainers";
|
||||
import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { buildJwtAbility } from "@trigger.dev/plugins";
|
||||
import rbacPlugin, { type RbacAbility, type RoleBaseAccessController } from "@trigger.dev/rbac";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
|
||||
import { handlePublicTokenRequest } from "~/services/publicTokens.server";
|
||||
import { generateAdditionalApiKey, generateRootApiKey, hashApiKey } from "~/utils/apiKeys";
|
||||
import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures";
|
||||
|
||||
function request(body: unknown, accessToken = "tr_prod_test", expirationTime?: string | number) {
|
||||
return new Request("https://api.trigger.dev/api/v1/auth/public-tokens", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(
|
||||
expirationTime === undefined ? body : { ...(body as object), expirationTime }
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const environment = {
|
||||
id: "env_test",
|
||||
apiKey: "tr_prod_root_signing_secret",
|
||||
parentEnvironment: null,
|
||||
};
|
||||
|
||||
const permissiveAbility: RbacAbility = {
|
||||
can: () => true,
|
||||
canSuper: () => false,
|
||||
};
|
||||
|
||||
function controllerWithAbility(
|
||||
ability: RbacAbility,
|
||||
subject: "root" | "additional" = "additional"
|
||||
) {
|
||||
return {
|
||||
async authenticateBearer() {
|
||||
return {
|
||||
ok: true as const,
|
||||
environment,
|
||||
subject:
|
||||
subject === "root"
|
||||
? {
|
||||
type: "user" as const,
|
||||
userId: "user_test",
|
||||
organizationId: "org_test",
|
||||
}
|
||||
: {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "key_test",
|
||||
restricted: ability !== permissiveAbility,
|
||||
organizationId: "org_test",
|
||||
},
|
||||
ability,
|
||||
};
|
||||
},
|
||||
} as unknown as Pick<RoleBaseAccessController, "authenticateBearer">;
|
||||
}
|
||||
|
||||
async function responseJson(response: Response) {
|
||||
return response.json() as Promise<Record<string, any>>;
|
||||
}
|
||||
|
||||
function telemetryRecorder(): ApiKeyTelemetry {
|
||||
return {
|
||||
recordOperation: vi.fn(),
|
||||
recordPublicTokenMint: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /api/v1/auth/public-tokens", () => {
|
||||
it("lets root and unrestricted additional keys mint arbitrary scopes", async () => {
|
||||
for (const controller of [
|
||||
controllerWithAbility(permissiveAbility, "root"),
|
||||
controllerWithAbility(permissiveAbility, "additional"),
|
||||
]) {
|
||||
const response = await handlePublicTokenRequest(
|
||||
request({ scopes: ["read:runs", "custom:resources:value"] }),
|
||||
controller
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const { token } = await responseJson(response);
|
||||
const validation = await validateJWT(token, environment.apiKey);
|
||||
expect(validation.ok).toBe(true);
|
||||
if (!validation.ok) continue;
|
||||
expect(validation.payload).toMatchObject({
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: ["read:runs", "custom:resources:value"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("records successful and rejected mint outcomes", async () => {
|
||||
const telemetry = telemetryRecorder();
|
||||
const controller = controllerWithAbility(buildJwtAbility(["read:runs"]));
|
||||
|
||||
const allowed = await handlePublicTokenRequest(
|
||||
request({ scopes: ["read:runs"] }),
|
||||
controller,
|
||||
telemetry
|
||||
);
|
||||
const denied = await handlePublicTokenRequest(
|
||||
request({ scopes: ["write:runs"] }),
|
||||
controller,
|
||||
telemetry
|
||||
);
|
||||
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(denied.status).toBe(403);
|
||||
expect(telemetry.recordPublicTokenMint).toHaveBeenNthCalledWith(1, "success");
|
||||
expect(telemetry.recordPublicTokenMint).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"rejected",
|
||||
"scope_not_allowed"
|
||||
);
|
||||
});
|
||||
|
||||
it("allows restricted subsets and rejects excess scopes", async () => {
|
||||
const controller = controllerWithAbility(
|
||||
buildJwtAbility(["read:runs", "trigger:tasks:send-email"])
|
||||
);
|
||||
|
||||
const allowed = await handlePublicTokenRequest(
|
||||
request({ scopes: ["read:runs:run_123", "trigger:tasks:send-email"] }),
|
||||
controller
|
||||
);
|
||||
expect(allowed.status).toBe(200);
|
||||
|
||||
const denied = await handlePublicTokenRequest(
|
||||
request({ scopes: ["read:runs", "write:runs", "trigger:tasks"] }),
|
||||
controller
|
||||
);
|
||||
expect(denied.status).toBe(403);
|
||||
await expect(responseJson(denied)).resolves.toMatchObject({
|
||||
code: "scopes_exceed_key_access",
|
||||
deniedScopes: ["write:runs", "trigger:tasks"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a type-level scope when the key only has per-id access", async () => {
|
||||
const response = await handlePublicTokenRequest(
|
||||
request({ scopes: ["trigger:tasks"] }),
|
||||
controllerWithAbility(buildJwtAbility(["trigger:tasks:send-email"]))
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expect(responseJson(response)).resolves.toMatchObject({
|
||||
deniedScopes: ["trigger:tasks"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty scopes", async () => {
|
||||
const response = await handlePublicTokenRequest(
|
||||
request({ scopes: [] }),
|
||||
controllerWithAbility(permissiveAbility)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects expirations longer than 30 days", async () => {
|
||||
const response = await handlePublicTokenRequest(
|
||||
request({ scopes: ["read:runs"] }, undefined, "31d"),
|
||||
controllerWithAbility(permissiveAbility)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(responseJson(response)).resolves.toMatchObject({
|
||||
error: "Expiration time cannot exceed 30 days",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["-5m", "5m ago"])("rejects an expiration in the past (%s)", async (expirationTime) => {
|
||||
const response = await handlePublicTokenRequest(
|
||||
request({ scopes: ["read:runs"] }, undefined, expirationTime),
|
||||
controllerWithAbility(permissiveAbility)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(responseJson(response)).resolves.toMatchObject({
|
||||
error: "Expiration time must be in the future",
|
||||
});
|
||||
});
|
||||
|
||||
it("signs the exp it validated rather than re-parsing the input string", async () => {
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const response = await handlePublicTokenRequest(
|
||||
request({ scopes: ["read:runs"] }, undefined, "10m"),
|
||||
controllerWithAbility(permissiveAbility)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const { token } = (await responseJson(response)) as { token: string };
|
||||
const validation = await validateJWT(token, environment.apiKey);
|
||||
|
||||
expect(validation.ok).toBe(true);
|
||||
// A single parse governs both the cap check and the claim.
|
||||
const exp = (validation as { payload: { exp: number } }).payload.exp;
|
||||
expect(exp).toBeGreaterThanOrEqual(before + 600);
|
||||
expect(exp).toBeLessThanOrEqual(before + 605);
|
||||
});
|
||||
|
||||
it("does not allow a public JWT bearer to mint another token", async () => {
|
||||
const jwt = await generateJWT({
|
||||
secretKey: environment.apiKey,
|
||||
payload: { sub: environment.id, pub: true, scopes: ["read:runs"] },
|
||||
expirationTime: "15m",
|
||||
});
|
||||
const controller = {
|
||||
async authenticateBearer(_request: Request, options?: { allowJWT?: boolean }) {
|
||||
return options?.allowJWT
|
||||
? ({
|
||||
ok: true,
|
||||
environment,
|
||||
subject: {
|
||||
type: "publicJWT",
|
||||
environmentId: environment.id,
|
||||
organizationId: "org_test",
|
||||
},
|
||||
ability: buildJwtAbility(["read:runs"]),
|
||||
} as const)
|
||||
: ({ ok: false, status: 401, error: "Invalid API key" } as const);
|
||||
},
|
||||
} as unknown as Pick<RoleBaseAccessController, "authenticateBearer">;
|
||||
|
||||
const response = await handlePublicTokenRequest(
|
||||
request({ scopes: ["read:runs"] }, jwt),
|
||||
controller
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
function makeController(prisma: PrismaClient) {
|
||||
return rbacPlugin.create({ primary: prisma, replica: prisma }, { forceFallback: true });
|
||||
}
|
||||
|
||||
postgresTest(
|
||||
"minted tokens round-trip after the root key is rotated",
|
||||
async ({ prisma }) => {
|
||||
const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma);
|
||||
const originalRootKey = generateRootApiKey("PRODUCTION").apiKey;
|
||||
const rotatedSigningKey = generateRootApiKey("PRODUCTION").apiKey;
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: uniqueId("env"),
|
||||
apiKey: originalRootKey,
|
||||
pkApiKey: uniqueId("pk"),
|
||||
shortcode: uniqueId("sc"),
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
orgMemberId: orgMember.id,
|
||||
},
|
||||
});
|
||||
const additionalKey = generateAdditionalApiKey("PRODUCTION").apiKey;
|
||||
await prisma.apiKey.create({
|
||||
data: {
|
||||
name: "Token minter",
|
||||
keyHash: hashApiKey(additionalKey),
|
||||
lastFour: additionalKey.slice(-4),
|
||||
runtimeEnvironmentId: environment.id,
|
||||
createdByUserId: user.id,
|
||||
presetId: "READ_ONLY",
|
||||
scopes: ["read:runs"],
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.update({
|
||||
where: { id: environment.id },
|
||||
data: { apiKey: rotatedSigningKey },
|
||||
});
|
||||
|
||||
const controller = makeController(prisma);
|
||||
const mintResponse = await handlePublicTokenRequest(
|
||||
request({ scopes: ["read:runs"], oneTimeUse: true }, additionalKey),
|
||||
controller
|
||||
);
|
||||
expect(mintResponse.status).toBe(200);
|
||||
const { token } = await responseJson(mintResponse);
|
||||
|
||||
const authResult = await controller.authenticateBearer(
|
||||
new Request("https://api.trigger.dev/api/v1/runs", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
{ allowJWT: true }
|
||||
);
|
||||
|
||||
expect(authResult.ok).toBe(true);
|
||||
if (!authResult.ok) return;
|
||||
expect(authResult.environment.id).toBe(environment.id);
|
||||
expect(authResult.jwt?.oneTimeUse).toBe(true);
|
||||
expect(authResult.ability.can("read", { type: "runs" })).toBe(true);
|
||||
},
|
||||
60_000
|
||||
);
|
||||
@@ -23,6 +23,12 @@ export type ApiKeyPreset = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/**
|
||||
* The plugin-generated, all-task scope template for this preset. The host
|
||||
* uses it only to preview the policy before creation; `prepareApiKeyPolicy`
|
||||
* remains the authorization source of truth.
|
||||
*/
|
||||
scopes: string[];
|
||||
usesTaskSelection: boolean;
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user