feat(cli,webapp): allow deploys with environment API keys (#4561)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Allow `trigger deploy` to authenticate with an environment API key from `TRIGGER_ACCESS_TOKEN`.
|
||||
+3
-1
@@ -874,7 +874,7 @@ const API_KEY_EXPIRATIONS = [
|
||||
{ value: "never", label: "Never" },
|
||||
];
|
||||
|
||||
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "envvars";
|
||||
type CapId = "tasks" | "runs" | "batches" | "queues" | "deployments" | "branches" | "envvars";
|
||||
|
||||
// Capability rows shown in the scope pane, in a fixed order so two presets read
|
||||
// as a diff of the same list rather than a reshuffled one.
|
||||
@@ -884,6 +884,7 @@ const SCOPE_CAPABILITIES: [CapId, string][] = [
|
||||
["batches", "Batches"],
|
||||
["queues", "Queues"],
|
||||
["deployments", "Deployments"],
|
||||
["branches", "Preview branches"],
|
||||
["envvars", "Environment variables"],
|
||||
];
|
||||
|
||||
@@ -920,6 +921,7 @@ const SCOPE_CAPABILITY_BY_SCOPE: Record<string, [CapId, number]> = {
|
||||
"write:queues": ["queues", 2],
|
||||
"read:deployments": ["deployments", 1],
|
||||
"write:deployments": ["deployments", 2],
|
||||
"write:branches": ["branches", 3],
|
||||
"read:envvars": ["envvars", 1],
|
||||
"write:envvars": ["envvars", 2],
|
||||
};
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import {
|
||||
authenticateEnvironmentScopedApiRequest,
|
||||
apiKeyForProjectEnvironmentBootstrap,
|
||||
authenticateEnvironmentBootstrapRequest,
|
||||
authorizePatEnvironmentAccess,
|
||||
presentedApiKeyFromAuthentication,
|
||||
} from "~/services/environmentVariableApiAccess.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
@@ -30,9 +30,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const { projectRef, env } = parsedParams.data;
|
||||
|
||||
try {
|
||||
// PAT/OAT authenticate on the legacy path; machine API keys go through
|
||||
// the RBAC controller so additional keys (and their grants) are enforced.
|
||||
const authResult = await authenticateEnvironmentScopedApiRequest(request, "read", "apiKeys");
|
||||
// PAT/OAT authenticate on the legacy path; machine API keys only need to
|
||||
// prove they are valid because bootstrap echoes the same key back.
|
||||
const authResult = await authenticateEnvironmentBootstrapRequest(request);
|
||||
if (!authResult.ok) {
|
||||
return json({ error: authResult.error }, { status: authResult.status });
|
||||
}
|
||||
@@ -46,29 +46,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
);
|
||||
|
||||
// User tokens bootstrap the environment's secret key, so gate them on
|
||||
// env-tier read:apiKeys. Machine credentials are checked against the same
|
||||
// permission before their presented key is returned below.
|
||||
const denied = await authorizePatEnvironmentAccess({
|
||||
request,
|
||||
authType: authenticationResult.type,
|
||||
ability:
|
||||
authenticationResult.type === "apiKey" && authenticationResult.result.ok
|
||||
? authenticationResult.result.ability
|
||||
: undefined,
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.project.id,
|
||||
envType: environment.type,
|
||||
resource: "apiKeys",
|
||||
action: "read",
|
||||
});
|
||||
if (denied) return denied;
|
||||
|
||||
// API-key callers already possess a valid environment credential. Reuse
|
||||
// exactly what they presented instead of exchanging it for the root key.
|
||||
const presentedApiKey = presentedApiKeyFromAuthentication(authenticationResult);
|
||||
// env-tier read:apiKeys. A machine credential never receives that root key.
|
||||
if (authenticationResult.type !== "apiKey") {
|
||||
const denied = await authorizePatEnvironmentAccess({
|
||||
request,
|
||||
authType: authenticationResult.type,
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.project.id,
|
||||
envType: environment.type,
|
||||
resource: "apiKeys",
|
||||
action: "read",
|
||||
});
|
||||
if (denied) return denied;
|
||||
}
|
||||
|
||||
const result: GetProjectEnvResponse = {
|
||||
apiKey: presentedApiKey ?? environment.apiKey,
|
||||
apiKey: apiKeyForProjectEnvironmentBootstrap(authenticationResult, environment.apiKey),
|
||||
name: environment.project.name,
|
||||
apiUrl: processEnv.API_ORIGIN ?? processEnv.APP_ORIGIN,
|
||||
projectId: environment.project.id,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateRequest } from "~/services/apiAuth.server";
|
||||
import { authenticateRequestWithScopedApiKey } from "~/services/apiAuth.server";
|
||||
import { ArchiveBranchService } from "~/services/archiveBranch.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { toBranchableEnvironmentType } from "~/utils/branchableEnvironment";
|
||||
@@ -24,15 +24,25 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
logger.info("Archive branch", { url: request.url, params });
|
||||
|
||||
const authenticationResult = await authenticateRequest(request, {
|
||||
const authentication = await authenticateRequestWithScopedApiKey(request, {
|
||||
personalAccessToken: true,
|
||||
organizationAccessToken: true,
|
||||
apiKey: false,
|
||||
apiKey: {
|
||||
action: "write",
|
||||
resource: { type: "branches" },
|
||||
allowPreviewParent: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
if (!authentication.ok) {
|
||||
return json({ error: authentication.error }, { status: authentication.status });
|
||||
}
|
||||
const authenticationResult = authentication.authentication;
|
||||
|
||||
const apiKeyEnvironment =
|
||||
authenticationResult.type === "apiKey" && authenticationResult.result.ok
|
||||
? authenticationResult.result.environment
|
||||
: undefined;
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
@@ -54,26 +64,52 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
const { env, branch } = parsed.data;
|
||||
|
||||
// API keys can only archive Preview branches
|
||||
if (
|
||||
authenticationResult.type === "apiKey" &&
|
||||
(!apiKeyEnvironment ||
|
||||
apiKeyEnvironment.type !== "PREVIEW" ||
|
||||
apiKeyEnvironment.parentEnvironmentId !== null ||
|
||||
env !== "preview")
|
||||
) {
|
||||
return json(
|
||||
{ error: "API keys must belong to the parent Preview environment." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// API keys can only act on their own project
|
||||
if (
|
||||
authenticationResult.type === "apiKey" &&
|
||||
apiKeyEnvironment?.project.externalRef !== projectRef
|
||||
) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const environmentType = toBranchableEnvironmentType(env);
|
||||
|
||||
const organizationFilter =
|
||||
authenticationResult.type === "organizationAccessToken"
|
||||
? { id: authenticationResult.result.organizationId }
|
||||
: authenticationResult.type === "apiKey"
|
||||
? { id: apiKeyEnvironment!.organizationId }
|
||||
: {
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.result.userId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const environments = await prisma.runtimeEnvironment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
archivedAt: true,
|
||||
},
|
||||
where: {
|
||||
organization:
|
||||
authenticationResult.type === "organizationAccessToken"
|
||||
? { id: authenticationResult.result.organizationId }
|
||||
: {
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.result.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
organization: organizationFilter,
|
||||
// Dev branches are per-org-member: only the owner may archive their own.
|
||||
...(authenticationResult.type !== "organizationAccessToken" &&
|
||||
environmentType === "DEVELOPMENT"
|
||||
...(authenticationResult.type === "personalAccessToken" && environmentType === "DEVELOPMENT"
|
||||
? { orgMember: { userId: authenticationResult.result.userId } }
|
||||
: {}),
|
||||
project: {
|
||||
@@ -91,7 +127,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const activeEnvironments = environments.filter((env) => env.archivedAt === null);
|
||||
|
||||
if (
|
||||
authenticationResult.type === "organizationAccessToken" &&
|
||||
authenticationResult.type !== "personalAccessToken" &&
|
||||
environmentType === "DEVELOPMENT" &&
|
||||
activeEnvironments.length > 1
|
||||
) {
|
||||
@@ -110,15 +146,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "Branch already archived" }, { status: 400 });
|
||||
}
|
||||
|
||||
let orgFilter:
|
||||
| { type: "userMembership"; userId: string }
|
||||
| { type: "orgId"; organizationId: string };
|
||||
if (authenticationResult.type === "personalAccessToken") {
|
||||
orgFilter = { type: "userMembership", userId: authenticationResult.result.userId };
|
||||
} else if (authenticationResult.type === "organizationAccessToken") {
|
||||
orgFilter = { type: "orgId", organizationId: authenticationResult.result.organizationId };
|
||||
} else {
|
||||
orgFilter = { type: "orgId", organizationId: apiKeyEnvironment!.organizationId };
|
||||
}
|
||||
|
||||
const service = new ArchiveBranchService();
|
||||
const result = await service.call(
|
||||
authenticationResult.type === "organizationAccessToken"
|
||||
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
|
||||
: { type: "userMembership", userId: authenticationResult.result.userId },
|
||||
{
|
||||
environmentId: environment.id,
|
||||
}
|
||||
);
|
||||
const result = await service.call(orgFilter, {
|
||||
environmentId: environment.id,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return json(result);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3";
|
||||
import { DEFAULT_DEV_BRANCH, isDefaultDevBranch } from "@trigger.dev/core/v3/utils/gitBranch";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateRequest } from "~/services/apiAuth.server";
|
||||
import { authenticateRequestWithScopedApiKey } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { UpsertBranchService } from "~/services/upsertBranch.server";
|
||||
@@ -21,14 +21,24 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
logger.info("project upsert branch", { url: request.url });
|
||||
|
||||
const authenticationResult = await authenticateRequest(request, {
|
||||
const authentication = await authenticateRequestWithScopedApiKey(request, {
|
||||
personalAccessToken: true,
|
||||
organizationAccessToken: true,
|
||||
apiKey: false,
|
||||
apiKey: {
|
||||
action: "write",
|
||||
resource: { type: "branches" },
|
||||
allowPreviewParent: true,
|
||||
},
|
||||
});
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
if (!authentication.ok) {
|
||||
return json({ error: authentication.error }, { status: authentication.status });
|
||||
}
|
||||
const authenticationResult = authentication.authentication;
|
||||
|
||||
const apiKeyEnvironment =
|
||||
authenticationResult.type === "apiKey" && authenticationResult.result.ok
|
||||
? authenticationResult.result.environment
|
||||
: undefined;
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
@@ -38,24 +48,32 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
const { projectRef } = parsedParams.data;
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
externalRef: projectRef,
|
||||
organization:
|
||||
authenticationResult.type === "organizationAccessToken"
|
||||
? { id: authenticationResult.result.organizationId }
|
||||
: {
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.result.userId,
|
||||
let project: { id: string } | null | undefined;
|
||||
if (authenticationResult.type === "apiKey") {
|
||||
project =
|
||||
apiKeyEnvironment?.project.externalRef === projectRef
|
||||
? { id: apiKeyEnvironment.project.id }
|
||||
: undefined;
|
||||
} else {
|
||||
project = await prisma.project.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
externalRef: projectRef,
|
||||
organization:
|
||||
authenticationResult.type === "organizationAccessToken"
|
||||
? { id: authenticationResult.result.organizationId }
|
||||
: {
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.result.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!project) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
@@ -72,13 +90,30 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
const { branch, env, git } = parsed.data;
|
||||
|
||||
if (env === "development" && authenticationResult.type === "organizationAccessToken") {
|
||||
if (env === "development" && authenticationResult.type !== "personalAccessToken") {
|
||||
return json(
|
||||
{ error: "Cannot create dev branches with organization access tokens." },
|
||||
{
|
||||
error:
|
||||
authenticationResult.type === "apiKey"
|
||||
? "API keys can only create Preview branches."
|
||||
: "Cannot create dev branches with organization access tokens.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
authenticationResult.type === "apiKey" &&
|
||||
(!apiKeyEnvironment ||
|
||||
apiKeyEnvironment.type !== "PREVIEW" ||
|
||||
apiKeyEnvironment.parentEnvironmentId !== null)
|
||||
) {
|
||||
return json(
|
||||
{ error: "API keys must belong to the parent Preview environment." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (env === "development" && isDefaultDevBranch(branch)) {
|
||||
return json(
|
||||
{ error: `Cannot create dev branch with name '${DEFAULT_DEV_BRANCH}'.` },
|
||||
@@ -86,24 +121,33 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const service = new UpsertBranchService();
|
||||
const result = await service.call(
|
||||
authenticationResult.type === "organizationAccessToken"
|
||||
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
|
||||
: { type: "userMembership", userId: authenticationResult.result.userId },
|
||||
{
|
||||
env,
|
||||
branchName: branch,
|
||||
projectId: project.id,
|
||||
git,
|
||||
let orgFilter:
|
||||
| { type: "userMembership"; userId: string }
|
||||
| { type: "orgId"; organizationId: string };
|
||||
if (authenticationResult.type === "personalAccessToken") {
|
||||
orgFilter = { type: "userMembership", userId: authenticationResult.result.userId };
|
||||
} else if (authenticationResult.type === "organizationAccessToken") {
|
||||
orgFilter = { type: "orgId", organizationId: authenticationResult.result.organizationId };
|
||||
} else {
|
||||
if (!apiKeyEnvironment) {
|
||||
return json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
);
|
||||
orgFilter = { type: "orgId", organizationId: apiKeyEnvironment.organizationId };
|
||||
}
|
||||
|
||||
const service = new UpsertBranchService();
|
||||
const result = await service.call(orgFilter, {
|
||||
env,
|
||||
branchName: branch,
|
||||
projectId: project.id,
|
||||
git,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return json(result.branch);
|
||||
return json({ id: result.branch.id });
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
findEnvironmentByPublicApiKey,
|
||||
toAuthenticated,
|
||||
} from "~/models/runtimeEnvironment.server";
|
||||
import type { RbacAbility, RbacResource, UserActorClaims } from "@trigger.dev/rbac";
|
||||
import type {
|
||||
BearerAuthOptions,
|
||||
RbacAbility,
|
||||
RbacResource,
|
||||
UserActorClaims,
|
||||
} from "@trigger.dev/rbac";
|
||||
import { assertUserActorEnvironment } from "./userActorEnvironment.server";
|
||||
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import { logger } from "./logger.server";
|
||||
@@ -33,6 +38,7 @@ import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
|
||||
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
|
||||
import {
|
||||
authenticateAuthorizeBearerWithTelemetry,
|
||||
authenticateBearerWithTelemetry,
|
||||
observeLegacyBearerAuthentication,
|
||||
} from "~/services/authTelemetry.server";
|
||||
|
||||
@@ -295,23 +301,11 @@ async function authenticateApiKeyWithFailure(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate an API-key request for a legacy (non-apiBuilder) route that
|
||||
* needs to accept granular additional keys, then enforce that the key's ability
|
||||
* authorizes `action` on `resource`. Root keys (and grace-window root keys)
|
||||
* carry the unrestricted `admin` ability, preserving pre-granular behavior.
|
||||
*
|
||||
* Only apiKey credentials are accepted (no PAT / org token / public key). Use
|
||||
* this for routes previously guarded by a bare `authenticateApiRequest` call.
|
||||
*/
|
||||
export async function authenticateApiKeyWithScope(
|
||||
/** Authenticate a private API-key request without requiring a resource scope. */
|
||||
export async function authenticateApiKeyRequest(
|
||||
request: Request,
|
||||
{
|
||||
action,
|
||||
resource,
|
||||
allowJWT = false,
|
||||
}: { action: string; resource: RbacResource; allowJWT?: boolean },
|
||||
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
|
||||
options: BearerAuthOptions = {},
|
||||
authenticateBearer: typeof authenticateBearerWithTelemetry = authenticateBearerWithTelemetry
|
||||
): Promise<
|
||||
| { ok: true; authentication: ApiAuthenticationResultSuccess }
|
||||
| { ok: false; status: 401 | 403; error: string }
|
||||
@@ -321,7 +315,7 @@ export async function authenticateApiKeyWithScope(
|
||||
return { ok: false, status: 401, error: "Invalid or Missing API key" };
|
||||
}
|
||||
|
||||
const result = await authorizeBearer(request, { action, resource }, { allowJWT });
|
||||
const result = await authenticateBearer(request, options);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
@@ -338,6 +332,100 @@ export async function authenticateApiKeyWithScope(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate an API-key request for a legacy (non-apiBuilder) route that
|
||||
* needs to accept granular additional keys, then enforce that the key's ability
|
||||
* authorizes `action` on `resource`. Root keys (and grace-window root keys)
|
||||
* carry the unrestricted `admin` ability, preserving pre-granular behavior.
|
||||
*
|
||||
* Only apiKey credentials are accepted (no PAT / org token / public key). Use
|
||||
* this for routes previously guarded by a bare `authenticateApiRequest` call.
|
||||
*/
|
||||
export type ApiKeyScopeAuthorization = {
|
||||
action: string;
|
||||
resource: RbacResource;
|
||||
allowJWT?: boolean;
|
||||
allowPreviewParent?: boolean;
|
||||
};
|
||||
|
||||
export async function authenticateApiKeyWithScope(
|
||||
request: Request,
|
||||
{ action, resource, allowJWT = false, allowPreviewParent = false }: ApiKeyScopeAuthorization,
|
||||
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
|
||||
): Promise<
|
||||
| { ok: true; authentication: ApiAuthenticationResultSuccess }
|
||||
| { ok: false; status: 401 | 403; error: string }
|
||||
> {
|
||||
const apiKey = getApiKeyFromHeader(request.headers.get("Authorization"));
|
||||
if (!apiKey) {
|
||||
return { ok: false, status: 401, error: "Invalid or Missing API key" };
|
||||
}
|
||||
|
||||
const result = await authorizeBearer(
|
||||
request,
|
||||
{ action, resource },
|
||||
{ allowJWT, allowPreviewParent }
|
||||
);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authentication: {
|
||||
ok: true,
|
||||
apiKey,
|
||||
type: "PRIVATE",
|
||||
environment: result.environment,
|
||||
ability: result.ability,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ScopedApiKeyAuthenticationDependencies = {
|
||||
authenticateRequest: typeof authenticateRequest;
|
||||
authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope;
|
||||
};
|
||||
|
||||
export async function authenticateRequestWithScopedApiKey(
|
||||
request: Request,
|
||||
{
|
||||
personalAccessToken,
|
||||
organizationAccessToken,
|
||||
apiKey,
|
||||
}: {
|
||||
personalAccessToken: true;
|
||||
organizationAccessToken: true;
|
||||
apiKey: ApiKeyScopeAuthorization;
|
||||
},
|
||||
dependencies: ScopedApiKeyAuthenticationDependencies = {
|
||||
authenticateRequest,
|
||||
authenticateApiKeyWithScope,
|
||||
}
|
||||
): Promise<
|
||||
| { ok: true; authentication: AuthenticationResult }
|
||||
| { ok: false; status: 401 | 403; error: string }
|
||||
> {
|
||||
const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, {
|
||||
personalAccessToken,
|
||||
organizationAccessToken,
|
||||
apiKey: false,
|
||||
});
|
||||
if (userOrOrganizationAuthentication) {
|
||||
return { ok: true, authentication: userOrOrganizationAuthentication };
|
||||
}
|
||||
|
||||
const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, apiKey);
|
||||
if (!apiKeyAuthentication.ok) {
|
||||
return apiKeyAuthentication;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authentication: { type: "apiKey", result: apiKeyAuthentication.authentication },
|
||||
};
|
||||
}
|
||||
|
||||
export async function authenticateAuthorizationHeader(
|
||||
authorization: string,
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getMeter } from "@internal/tracing";
|
||||
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
|
||||
import { isPublicJWT } from "@trigger.dev/core/v3/jwt";
|
||||
import type { BearerAuthOptions } from "@trigger.dev/plugins";
|
||||
import type {
|
||||
BearerCredentialKind,
|
||||
BearerLookupPath,
|
||||
@@ -39,10 +40,10 @@ const telemetry = singleton("apiAuthTelemetry", () => {
|
||||
|
||||
export async function authenticateBearerWithTelemetry(
|
||||
request: Request,
|
||||
options: { allowJWT: boolean }
|
||||
options: BearerAuthOptions
|
||||
): Promise<HostBearerAuthResult> {
|
||||
const startedAt = performance.now();
|
||||
const classified = classifyCredential(request, options.allowJWT);
|
||||
const classified = classifyCredential(request, options.allowJWT ?? false);
|
||||
let final = { ...classified, result: "error" as ApiAuthResult };
|
||||
|
||||
try {
|
||||
@@ -79,7 +80,7 @@ export async function authenticateBearerWithTelemetry(
|
||||
export async function authenticateAuthorizeBearerWithTelemetry(
|
||||
request: Request,
|
||||
check: { action: string; resource: RbacResource },
|
||||
options: { allowJWT: boolean }
|
||||
options: BearerAuthOptions
|
||||
) {
|
||||
// Keep authentication telemetry consistent with apiBuilder: a valid
|
||||
// credential records a successful authentication even when the subsequent
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { isUserActorToken } from "@trigger.dev/rbac";
|
||||
import type { RbacAbility } from "@trigger.dev/rbac";
|
||||
import {
|
||||
authenticateApiKeyWithScope,
|
||||
authenticateApiKeyRequest,
|
||||
authenticateRequest,
|
||||
authenticateRequestWithScopedApiKey,
|
||||
type AuthenticationResult,
|
||||
type ScopedApiKeyAuthenticationDependencies,
|
||||
} from "~/services/apiAuth.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
|
||||
@@ -28,20 +30,52 @@ export function presentedApiKeyFromAuthentication(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function apiKeyForProjectEnvironmentBootstrap(
|
||||
authentication: AuthenticationResult,
|
||||
rootApiKey: string
|
||||
): string {
|
||||
return presentedApiKeyFromAuthentication(authentication) ?? rootApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep PAT/OAT authentication on the legacy path while routing machine API
|
||||
* keys through the RBAC controller, where plugin grants are applied.
|
||||
*/
|
||||
type AuthenticationDependencies = {
|
||||
type AuthenticationDependencies = ScopedApiKeyAuthenticationDependencies;
|
||||
|
||||
type BootstrapAuthenticationDependencies = {
|
||||
authenticateRequest: typeof authenticateRequest;
|
||||
authenticateApiKeyWithScope: typeof authenticateApiKeyWithScope;
|
||||
authenticateApiKeyRequest: typeof authenticateApiKeyRequest;
|
||||
};
|
||||
|
||||
export async function authenticateEnvironmentScopedApiRequest(
|
||||
request: Request,
|
||||
action: "read" | "write",
|
||||
resource: EnvironmentScopedResource,
|
||||
dependencies: AuthenticationDependencies = { authenticateRequest, authenticateApiKeyWithScope }
|
||||
dependencies?: AuthenticationDependencies
|
||||
): Promise<EnvironmentScopedAuthentication> {
|
||||
return authenticateRequestWithScopedApiKey(
|
||||
request,
|
||||
{
|
||||
personalAccessToken: true,
|
||||
organizationAccessToken: true,
|
||||
apiKey: { action, resource: { type: resource } },
|
||||
},
|
||||
dependencies
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap accepts any valid private environment key. Unlike env-var routes,
|
||||
* it intentionally does not require a resource scope because it only echoes
|
||||
* the credential the caller already presented.
|
||||
*/
|
||||
export async function authenticateEnvironmentBootstrapRequest(
|
||||
request: Request,
|
||||
dependencies: BootstrapAuthenticationDependencies = {
|
||||
authenticateRequest,
|
||||
authenticateApiKeyRequest,
|
||||
}
|
||||
): Promise<EnvironmentScopedAuthentication> {
|
||||
const userOrOrganizationAuthentication = await dependencies.authenticateRequest(request, {
|
||||
personalAccessToken: true,
|
||||
@@ -52,9 +86,8 @@ export async function authenticateEnvironmentScopedApiRequest(
|
||||
return { ok: true, authentication: userOrOrganizationAuthentication };
|
||||
}
|
||||
|
||||
const apiKeyAuthentication = await dependencies.authenticateApiKeyWithScope(request, {
|
||||
action,
|
||||
resource: { type: resource },
|
||||
const apiKeyAuthentication = await dependencies.authenticateApiKeyRequest(request, {
|
||||
allowPreviewParent: true,
|
||||
});
|
||||
if (!apiKeyAuthentication.ok) {
|
||||
return apiKeyAuthentication;
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
authenticateApiKeyRequest,
|
||||
authenticateApiKeyWithScope,
|
||||
authenticateRequestWithScopedApiKey,
|
||||
} from "~/services/apiAuth.server";
|
||||
|
||||
const authorizeBearer = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("authenticateApiKeyWithScope", () => {
|
||||
it("returns 401 without a bearer credential", async () => {
|
||||
const result = await authenticateApiKeyWithScope(
|
||||
@@ -59,7 +67,7 @@ describe("authenticateApiKeyWithScope", () => {
|
||||
expect(authorizeBearer).toHaveBeenCalledWith(
|
||||
request,
|
||||
{ action: "read", resource: { type: "envvars" } },
|
||||
{ allowJWT: true }
|
||||
{ allowJWT: true, allowPreviewParent: false }
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
@@ -73,6 +81,59 @@ describe("authenticateApiKeyWithScope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows branch creation to authenticate against the Preview parent", async () => {
|
||||
const environment = { id: "env_preview" };
|
||||
const ability = { can: vi.fn(() => true), canSuper: vi.fn(() => true) };
|
||||
authorizeBearer.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
environment,
|
||||
ability,
|
||||
subject: { type: "apiKey", apiKeyId: "key_123" },
|
||||
});
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { Authorization: "Bearer tr_preview_sk_test" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
authenticateApiKeyWithScope(
|
||||
request,
|
||||
{
|
||||
action: "write",
|
||||
resource: { type: "branches" },
|
||||
allowPreviewParent: true,
|
||||
},
|
||||
authorizeBearer
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(authorizeBearer).toHaveBeenCalledWith(
|
||||
request,
|
||||
{ action: "write", resource: { type: "branches" } },
|
||||
{ allowJWT: false, allowPreviewParent: true }
|
||||
);
|
||||
});
|
||||
|
||||
it("authenticates a valid API key without a resource check", async () => {
|
||||
const environment = { id: "env_123" };
|
||||
const ability = { can: vi.fn(() => false), canSuper: vi.fn(() => false) };
|
||||
const authenticateBearer = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
environment,
|
||||
ability,
|
||||
subject: { type: "apiKey", apiKeyId: "key_123" },
|
||||
});
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { Authorization: "Bearer tr_prod_sk_test" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
authenticateApiKeyRequest(request, { allowPreviewParent: true }, authenticateBearer)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
authentication: { apiKey: "tr_prod_sk_test", environment },
|
||||
});
|
||||
expect(ability.can).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns authorization failures from the controller", async () => {
|
||||
const ability = { can: vi.fn(() => false), canSuper: vi.fn(() => false) };
|
||||
authorizeBearer.mockResolvedValueOnce({
|
||||
@@ -94,3 +155,61 @@ describe("authenticateApiKeyWithScope", () => {
|
||||
expect(ability.can).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticateRequestWithScopedApiKey", () => {
|
||||
const options = {
|
||||
personalAccessToken: true as const,
|
||||
organizationAccessToken: true as const,
|
||||
apiKey: {
|
||||
action: "write",
|
||||
resource: { type: "branches" },
|
||||
allowPreviewParent: true,
|
||||
},
|
||||
};
|
||||
|
||||
it("keeps user and organization tokens on the legacy path", async () => {
|
||||
const authentication = {
|
||||
type: "personalAccessToken",
|
||||
result: { userId: "user_123" },
|
||||
} as const;
|
||||
const authenticateRequest = vi.fn().mockResolvedValueOnce(authentication);
|
||||
const authenticateApiKeyWithScope = vi.fn();
|
||||
|
||||
await expect(
|
||||
authenticateRequestWithScopedApiKey(new Request("https://example.com"), options, {
|
||||
authenticateRequest,
|
||||
authenticateApiKeyWithScope,
|
||||
})
|
||||
).resolves.toEqual({ ok: true, authentication });
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(expect.any(Request), {
|
||||
personalAccessToken: true,
|
||||
organizationAccessToken: true,
|
||||
apiKey: false,
|
||||
});
|
||||
expect(authenticateApiKeyWithScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses scoped RBAC authentication for API keys", async () => {
|
||||
const apiKeyAuthentication = {
|
||||
ok: true,
|
||||
apiKey: "tr_preview_sk_test",
|
||||
type: "PRIVATE",
|
||||
environment: {},
|
||||
} as const;
|
||||
const authenticateRequest = vi.fn().mockResolvedValueOnce(undefined);
|
||||
const authenticateApiKeyWithScope = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, authentication: apiKeyAuthentication });
|
||||
|
||||
await expect(
|
||||
authenticateRequestWithScopedApiKey(new Request("https://example.com"), options, {
|
||||
authenticateRequest,
|
||||
authenticateApiKeyWithScope,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
authentication: { type: "apiKey", result: apiKeyAuthentication },
|
||||
});
|
||||
expect(authenticateApiKeyWithScope).toHaveBeenCalledWith(expect.any(Request), options.apiKey);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
apiKeyForProjectEnvironmentBootstrap,
|
||||
authenticateEnvironmentBootstrapRequest,
|
||||
authenticateEnvVarApiRequest,
|
||||
presentedApiKeyFromAuthentication,
|
||||
} from "~/services/environmentVariableApiAccess.server";
|
||||
|
||||
const authenticateRequest = vi.fn();
|
||||
const authenticateApiKeyRequest = vi.fn();
|
||||
const authenticateApiKeyWithScope = vi.fn();
|
||||
const dependencies = { authenticateRequest, authenticateApiKeyWithScope };
|
||||
|
||||
@@ -31,6 +34,61 @@ describe("presentedApiKeyFromAuthentication", () => {
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("echoes only the presented API key during bootstrap", () => {
|
||||
expect(
|
||||
apiKeyForProjectEnvironmentBootstrap(
|
||||
{
|
||||
type: "apiKey",
|
||||
result: {
|
||||
ok: true,
|
||||
apiKey: "tr_prod_sk_presented",
|
||||
type: "PRIVATE",
|
||||
environment: {},
|
||||
},
|
||||
},
|
||||
"tr_prod_root"
|
||||
)
|
||||
).toBe("tr_prod_sk_presented");
|
||||
});
|
||||
|
||||
it("returns the root key to an authorized user token", () => {
|
||||
expect(
|
||||
apiKeyForProjectEnvironmentBootstrap(
|
||||
{
|
||||
type: "personalAccessToken",
|
||||
result: { userId: "user_123" },
|
||||
},
|
||||
"tr_prod_root"
|
||||
)
|
||||
).toBe("tr_prod_root");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticateEnvironmentBootstrapRequest", () => {
|
||||
it("authenticates API keys without requiring an API-key scope", async () => {
|
||||
authenticateRequest.mockResolvedValueOnce(undefined);
|
||||
const authentication = {
|
||||
ok: true,
|
||||
apiKey: "tr_preview_sk_presented",
|
||||
type: "PRIVATE",
|
||||
environment: {},
|
||||
};
|
||||
authenticateApiKeyRequest.mockResolvedValueOnce({ ok: true, authentication });
|
||||
|
||||
await expect(
|
||||
authenticateEnvironmentBootstrapRequest(new Request("https://example.com"), {
|
||||
authenticateRequest,
|
||||
authenticateApiKeyRequest,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
authentication: { type: "apiKey", result: authentication },
|
||||
});
|
||||
expect(authenticateApiKeyRequest).toHaveBeenCalledWith(expect.any(Request), {
|
||||
allowPreviewParent: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticateEnvVarApiRequest", () => {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authenticateEnvironmentScopedApiRequest: vi.fn<(...args: any[]) => Promise<any>>(),
|
||||
authenticateEnvironmentBootstrapRequest: vi.fn<(...args: any[]) => Promise<any>>(),
|
||||
authorizePatEnvironmentAccess: vi.fn<(...args: any[]) => Promise<any>>(),
|
||||
authenticatedEnvironmentForAuthentication: vi.fn<(...args: any[]) => Promise<any>>(),
|
||||
}));
|
||||
|
||||
vi.mock("~/services/environmentVariableApiAccess.server", () => ({
|
||||
authenticateEnvironmentScopedApiRequest: mocks.authenticateEnvironmentScopedApiRequest,
|
||||
authenticateEnvironmentBootstrapRequest: mocks.authenticateEnvironmentBootstrapRequest,
|
||||
authorizePatEnvironmentAccess: mocks.authorizePatEnvironmentAccess,
|
||||
presentedApiKeyFromAuthentication: (authentication: any) =>
|
||||
apiKeyForProjectEnvironmentBootstrap: (authentication: any, rootApiKey: string) =>
|
||||
authentication.type === "apiKey" && authentication.result.ok
|
||||
? authentication.result.apiKey
|
||||
: undefined,
|
||||
: rootApiKey,
|
||||
}));
|
||||
vi.mock("~/services/apiAuth.server", () => ({
|
||||
authenticatedEnvironmentForAuthentication: mocks.authenticatedEnvironmentForAuthentication,
|
||||
@@ -54,7 +54,7 @@ async function responseJson(response: Response) {
|
||||
|
||||
describe("project environment credential response", () => {
|
||||
beforeEach(() => {
|
||||
mocks.authenticateEnvironmentScopedApiRequest.mockReset();
|
||||
mocks.authenticateEnvironmentBootstrapRequest.mockReset();
|
||||
mocks.authorizePatEnvironmentAccess.mockReset();
|
||||
mocks.authenticatedEnvironmentForAuthentication.mockReset();
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("project environment credential response", () => {
|
||||
});
|
||||
|
||||
it("returns the presented API key", async () => {
|
||||
mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({
|
||||
mocks.authenticateEnvironmentBootstrapRequest.mockResolvedValue({
|
||||
ok: true,
|
||||
authentication: {
|
||||
type: "apiKey",
|
||||
@@ -83,10 +83,11 @@ describe("project environment credential response", () => {
|
||||
apiKey: "tr_prod_sk_presented",
|
||||
projectId: "proj_123",
|
||||
});
|
||||
expect(mocks.authorizePatEnvironmentAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not exchange a grace-window root key for the current root key", async () => {
|
||||
mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({
|
||||
mocks.authenticateEnvironmentBootstrapRequest.mockResolvedValue({
|
||||
ok: true,
|
||||
authentication: {
|
||||
type: "apiKey",
|
||||
@@ -109,7 +110,7 @@ describe("project environment credential response", () => {
|
||||
});
|
||||
|
||||
it("returns the root key to an authorized user token", async () => {
|
||||
mocks.authenticateEnvironmentScopedApiRequest.mockResolvedValue({
|
||||
mocks.authenticateEnvironmentBootstrapRequest.mockResolvedValue({
|
||||
ok: true,
|
||||
authentication: {
|
||||
type: "personalAccessToken",
|
||||
@@ -123,5 +124,6 @@ describe("project environment credential response", () => {
|
||||
await expect(responseJson(response)).resolves.toMatchObject({
|
||||
apiKey: "tr_prod_root_secret",
|
||||
});
|
||||
expect(mocks.authorizePatEnvironmentAccess).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +195,49 @@ describe("RBAC fallback — additional keys", () => {
|
||||
expect(apiKeyFind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
postgresTest("rejects revoked and expired additional keys", async ({ prisma }) => {
|
||||
const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma);
|
||||
const rbac = makeController(prisma);
|
||||
const environment = await createEnv(prisma, project.id, organization.id, {
|
||||
type: "PRODUCTION",
|
||||
orgMemberId: orgMember.id,
|
||||
});
|
||||
const revoked = generateAdditionalApiKey("PRODUCTION").apiKey;
|
||||
const expired = generateAdditionalApiKey("PRODUCTION").apiKey;
|
||||
|
||||
await prisma.apiKey.createMany({
|
||||
data: [
|
||||
{
|
||||
name: "Revoked deploy key",
|
||||
keyHash: createHash("sha256").update(revoked).digest("hex"),
|
||||
lastFour: revoked.slice(-4),
|
||||
runtimeEnvironmentId: environment.id,
|
||||
createdByUserId: user.id,
|
||||
scopes: ["admin"],
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
{
|
||||
name: "Expired deploy key",
|
||||
keyHash: createHash("sha256").update(expired).digest("hex"),
|
||||
lastFour: expired.slice(-4),
|
||||
runtimeEnvironmentId: environment.id,
|
||||
createdByUserId: user.id,
|
||||
scopes: ["admin"],
|
||||
expiresAt: new Date(Date.now() - 1_000),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(rbac.authenticateBearer(bearerRequest(revoked))).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
await expect(rbac.authenticateBearer(bearerRequest(expired))).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
});
|
||||
|
||||
postgresTest("authenticates an additional key and records its use", async ({ prisma }) => {
|
||||
const { organization, project, orgMember, user } = await createTestOrgProjectWithMember(prisma);
|
||||
const rbac = makeController(prisma);
|
||||
@@ -551,6 +594,41 @@ describe("RBAC fallback — branch header guards", () => {
|
||||
});
|
||||
});
|
||||
|
||||
postgresTest(
|
||||
"allows branch management to authenticate against the preview parent",
|
||||
async ({ prisma }) => {
|
||||
const { organization, project, user } = await createTestOrgProjectWithMember(prisma);
|
||||
const rbac = makeController(prisma);
|
||||
const previewParent = await createEnv(prisma, project.id, organization.id, {
|
||||
type: "PREVIEW",
|
||||
isBranchableEnvironment: true,
|
||||
});
|
||||
const additional = generateAdditionalApiKey("PREVIEW").apiKey;
|
||||
|
||||
await prisma.apiKey.create({
|
||||
data: {
|
||||
name: "Deploy key",
|
||||
keyHash: createHash("sha256").update(additional).digest("hex"),
|
||||
lastFour: additional.slice(-4),
|
||||
runtimeEnvironmentId: previewParent.id,
|
||||
createdByUserId: user.id,
|
||||
presetId: "DEPLOY_ONLY",
|
||||
scopes: ["write:branches"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await rbac.authenticateBearer(bearerRequest(additional), {
|
||||
allowPreviewParent: true,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.environment.id).toBe(previewParent.id);
|
||||
expect(result.ability.can("write", { type: "branches" })).toBe(true);
|
||||
expect(result.ability.can("write", { type: "deployments" })).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
// The "default" sentinel is DEVELOPMENT-only: it maps the dev root env to its
|
||||
// (branchless) self. For PREVIEW, "default" is an ordinary branch name, so a
|
||||
// PREVIEW branch literally named "default" is reachable and the request pivots
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Prisma, PrismaClient } from "@trigger.dev/database";
|
||||
import {
|
||||
scopesGrantFullAccess,
|
||||
type AuthenticatedEnvironment,
|
||||
type BearerAuthOptions,
|
||||
type BearerAuthResult,
|
||||
} from "@trigger.dev/plugins";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -80,7 +81,7 @@ export class BearerCredentialResolver {
|
||||
|
||||
async authenticate(
|
||||
request: Request,
|
||||
options?: { allowJWT?: boolean }
|
||||
options?: BearerAuthOptions
|
||||
): Promise<BearerCredentialResult> {
|
||||
// Deprecated public API keys (`pk_*` minted long before public JWTs
|
||||
// landed) are intentionally NOT handled here. That token format hasn't
|
||||
@@ -221,15 +222,16 @@ export class BearerCredentialResolver {
|
||||
};
|
||||
}
|
||||
|
||||
return this.resolveAdditionalKey(rawToken, branchName);
|
||||
return this.resolveAdditionalKey(rawToken, branchName, options?.allowPreviewParent);
|
||||
}
|
||||
|
||||
return this.resolveRootKey(rawToken, branchName);
|
||||
return this.resolveRootKey(rawToken, branchName, options?.allowPreviewParent);
|
||||
}
|
||||
|
||||
private async resolveRootKey(
|
||||
rawToken: string,
|
||||
branchName: string | null
|
||||
branchName: string | null,
|
||||
allowPreviewParent = false
|
||||
): Promise<BearerCredentialResult> {
|
||||
const include = environmentInclude(branchName);
|
||||
const now = new Date();
|
||||
@@ -270,7 +272,7 @@ export class BearerCredentialResolver {
|
||||
};
|
||||
}
|
||||
|
||||
const [branchError, resolvedEnvironment] = resolveBranch(env, branchName);
|
||||
const [branchError, resolvedEnvironment] = resolveBranch(env, branchName, allowPreviewParent);
|
||||
if (branchError !== null) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -296,7 +298,8 @@ export class BearerCredentialResolver {
|
||||
|
||||
private async resolveAdditionalKey(
|
||||
rawToken: string,
|
||||
branchName: string | null
|
||||
branchName: string | null,
|
||||
allowPreviewParent = false
|
||||
): Promise<BearerCredentialResult> {
|
||||
const resolution: BearerResolution = {
|
||||
credentialKind: "additional_api_key",
|
||||
@@ -321,7 +324,11 @@ export class BearerCredentialResolver {
|
||||
return { ok: false, status: 401, error: "Invalid API key", resolution };
|
||||
}
|
||||
|
||||
const [branchError, resolvedEnvironment] = resolveBranch(match.runtimeEnvironment, branchName);
|
||||
const [branchError, resolvedEnvironment] = resolveBranch(
|
||||
match.runtimeEnvironment,
|
||||
branchName,
|
||||
allowPreviewParent
|
||||
);
|
||||
if (branchError !== null) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -387,9 +394,10 @@ type BranchResolution =
|
||||
|
||||
function resolveBranch(
|
||||
environment: EnvironmentWithBranches,
|
||||
branchName: string | null
|
||||
branchName: string | null,
|
||||
allowPreviewParent: boolean
|
||||
): BranchResolution {
|
||||
if (environment.type === "PREVIEW" && !branchName) {
|
||||
if (environment.type === "PREVIEW" && !branchName && !allowPreviewParent) {
|
||||
return ["x-trigger-branch header required for preview env", null];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RbacUser,
|
||||
RbacSubject,
|
||||
RbacResource,
|
||||
BearerAuthOptions,
|
||||
BearerAuthResult,
|
||||
PatAuthResult,
|
||||
SessionAuthResult,
|
||||
@@ -87,7 +88,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
|
||||
|
||||
async authenticateBearer(
|
||||
request: Request,
|
||||
options?: { allowJWT?: boolean }
|
||||
options?: BearerAuthOptions
|
||||
): Promise<BearerAuthResult> {
|
||||
return this.bearer.authenticate(request, options);
|
||||
}
|
||||
@@ -174,7 +175,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
|
||||
async authenticateAuthorizeBearer(
|
||||
request: Request,
|
||||
check: { action: string; resource: RbacResource | RbacResource[] },
|
||||
options?: { allowJWT?: boolean }
|
||||
options?: BearerAuthOptions
|
||||
): Promise<BearerAuthResult> {
|
||||
const auth = await this.authenticateBearer(request, options);
|
||||
if (!auth.ok) return auth;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ApiKeyPolicyDescription,
|
||||
ApiKeyPreset,
|
||||
BearerAuthOptions,
|
||||
BearerAuthResult,
|
||||
Permission,
|
||||
PrepareApiKeyPolicyResult,
|
||||
@@ -22,7 +23,12 @@ import { RoleBaseAccessFallback } from "./fallback.js";
|
||||
// exchange route imports it to project requested scopes against it.
|
||||
export { CAPLESS_USER_ACTOR_SCOPES } from "./fallback.js";
|
||||
import { BearerCredentialResolver, type BearerResolution } from "./bearerCredentials.js";
|
||||
export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins";
|
||||
export type {
|
||||
BearerAuthOptions,
|
||||
RoleBaseAccessController,
|
||||
RbacAbility,
|
||||
RbacResource,
|
||||
} from "@trigger.dev/plugins";
|
||||
export type {
|
||||
BearerCredentialKind,
|
||||
BearerLookupPath,
|
||||
@@ -42,10 +48,7 @@ export type {
|
||||
export type HostBearerAuthResult = BearerAuthResult & { resolution: BearerResolution };
|
||||
|
||||
export type HostRbacController = Omit<Required<RoleBaseAccessController>, "authenticateBearer"> & {
|
||||
authenticateBearer(
|
||||
request: Request,
|
||||
options?: { allowJWT?: boolean }
|
||||
): Promise<HostBearerAuthResult>;
|
||||
authenticateBearer(request: Request, options?: BearerAuthOptions): Promise<HostBearerAuthResult>;
|
||||
};
|
||||
export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins";
|
||||
export { buildJwtAbility, scopesWithinAbility } from "./ability.js";
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { authenticateForDeploy, userIdForDeploy } from "../deploy/auth.js";
|
||||
import { buildImage } from "../deploy/buildImage.js";
|
||||
import {
|
||||
checkLogsForErrors,
|
||||
@@ -268,11 +269,12 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
|
||||
|
||||
verifyDirectory(dir, projectPath);
|
||||
|
||||
const authorization = await login({
|
||||
embedded: true,
|
||||
defaultApiUrl: options.apiUrl,
|
||||
const authorization = await authenticateForDeploy({
|
||||
accessToken: process.env.TRIGGER_ACCESS_TOKEN,
|
||||
apiUrl: process.env.TRIGGER_API_URL ?? options.apiUrl,
|
||||
profile: options.profile,
|
||||
silent: options.plain,
|
||||
login,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
@@ -368,7 +370,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
|
||||
config: resolvedConfig,
|
||||
dashboardUrl: authorization.dashboardUrl,
|
||||
options,
|
||||
userId: authorization.auth.tokenType === "personal" ? authorization.userId : undefined,
|
||||
userId: userIdForDeploy(authorization),
|
||||
gitMeta,
|
||||
});
|
||||
return;
|
||||
@@ -424,7 +426,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
|
||||
projectClient.client,
|
||||
{
|
||||
contentHash: buildManifest.contentHash,
|
||||
userId: authorization.auth.tokenType === "personal" ? authorization.userId : undefined,
|
||||
userId: userIdForDeploy(authorization),
|
||||
gitMeta,
|
||||
type: features.run_engine_v2 ? "MANAGED" : "V1",
|
||||
runtime: buildManifest.runtime,
|
||||
|
||||
@@ -14,7 +14,6 @@ import { loadConfig } from "../config.js";
|
||||
import { createGitMeta } from "../utilities/gitMeta.js";
|
||||
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import type { LoginResultOk } from "../utilities/session.js";
|
||||
import { spinner } from "../utilities/windows.js";
|
||||
import { verifyDirectory } from "./deploy.js";
|
||||
import { login } from "./login.js";
|
||||
@@ -129,7 +128,7 @@ async function _previewArchiveCommand(dir: string, options: PreviewCommandOption
|
||||
}
|
||||
|
||||
export async function archivePreviewBranch(
|
||||
authorization: LoginResultOk,
|
||||
authorization: { auth: { apiUrl: string; accessToken: string } },
|
||||
branch: string,
|
||||
project: string
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../utilities/configFiles.js", () => ({
|
||||
readAuthConfigProfile: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
import { authenticateForDeploy, userIdForDeploy } from "./auth.js";
|
||||
import { readAuthConfigProfile } from "../utilities/configFiles.js";
|
||||
|
||||
describe("authenticateForDeploy", () => {
|
||||
it("uses an API key from TRIGGER_ACCESS_TOKEN without logging in", async () => {
|
||||
let loginCalled = false;
|
||||
|
||||
const result = await authenticateForDeploy({
|
||||
accessToken: "tr_prod_sk_deploy",
|
||||
apiUrl: "https://example.trigger.dev",
|
||||
profile: "default",
|
||||
silent: true,
|
||||
login: async () => {
|
||||
loginCalled = true;
|
||||
return { ok: false, error: "should not be called" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(loginCalled).toBe(false);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
profile: "default",
|
||||
dashboardUrl: "https://example.trigger.dev",
|
||||
auth: {
|
||||
apiUrl: "https://example.trigger.dev",
|
||||
accessToken: "tr_prod_sk_deploy",
|
||||
tokenType: "apiKey",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("derives hosted dashboard links without user metadata", async () => {
|
||||
const result = await authenticateForDeploy({
|
||||
accessToken: "tr_preview_sk_deploy",
|
||||
apiUrl: "https://api.example.trigger.dev",
|
||||
profile: "default",
|
||||
silent: true,
|
||||
login: async () => ({ ok: false, error: "should not be called" }),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dashboardUrl: "https://example.trigger.dev",
|
||||
auth: { apiUrl: "https://api.example.trigger.dev" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the normal cloud URLs when no API URL is set", async () => {
|
||||
const result = await authenticateForDeploy({
|
||||
accessToken: "tr_prod_sk_deploy",
|
||||
profile: "default",
|
||||
silent: true,
|
||||
login: async () => ({ ok: false, error: "should not be called" }),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dashboardUrl: "https://cloud.trigger.dev",
|
||||
auth: { apiUrl: "https://api.trigger.dev" },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the saved profile's API URL for self-hosted instances", async () => {
|
||||
vi.mocked(readAuthConfigProfile).mockReturnValueOnce({
|
||||
apiUrl: "https://trigger.internal.example.com",
|
||||
});
|
||||
|
||||
const result = await authenticateForDeploy({
|
||||
accessToken: "tr_prod_sk_deploy",
|
||||
profile: "selfhosted",
|
||||
silent: true,
|
||||
login: async () => ({ ok: false, error: "should not be called" }),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dashboardUrl: "https://trigger.internal.example.com",
|
||||
auth: { apiUrl: "https://trigger.internal.example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an explicit API URL over the saved profile", async () => {
|
||||
vi.mocked(readAuthConfigProfile).mockReturnValueOnce({
|
||||
apiUrl: "https://trigger.internal.example.com",
|
||||
});
|
||||
|
||||
const result = await authenticateForDeploy({
|
||||
accessToken: "tr_prod_sk_deploy",
|
||||
apiUrl: "https://api.trigger.dev",
|
||||
profile: "selfhosted",
|
||||
silent: true,
|
||||
login: async () => ({ ok: false, error: "should not be called" }),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
auth: { apiUrl: "https://api.trigger.dev" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes a PAT through to the login path", async () => {
|
||||
let loginOptions: unknown;
|
||||
const result = await authenticateForDeploy({
|
||||
accessToken: "tr_pat_abc123",
|
||||
apiUrl: "https://example.trigger.dev",
|
||||
profile: "ci",
|
||||
silent: false,
|
||||
login: async (options) => {
|
||||
loginOptions = options;
|
||||
return { ok: false, error: "login result" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(loginOptions).toEqual({
|
||||
embedded: true,
|
||||
defaultApiUrl: "https://example.trigger.dev",
|
||||
profile: "ci",
|
||||
silent: false,
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: "login result" });
|
||||
});
|
||||
|
||||
it("passes an OAT through to the login path", async () => {
|
||||
let loginOptions: unknown;
|
||||
const result = await authenticateForDeploy({
|
||||
accessToken: "tr_oat_abc123",
|
||||
apiUrl: "https://example.trigger.dev",
|
||||
profile: "ci",
|
||||
silent: false,
|
||||
login: async (options) => {
|
||||
loginOptions = options;
|
||||
return { ok: false, error: "login result" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(loginOptions).toEqual({
|
||||
embedded: true,
|
||||
defaultApiUrl: "https://example.trigger.dev",
|
||||
profile: "ci",
|
||||
silent: false,
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: "login result" });
|
||||
});
|
||||
|
||||
it("keeps login authentication when no access token is set", async () => {
|
||||
let loginOptions: unknown;
|
||||
const result = await authenticateForDeploy({
|
||||
apiUrl: "https://example.trigger.dev",
|
||||
profile: "ci",
|
||||
silent: false,
|
||||
login: async (options) => {
|
||||
loginOptions = options;
|
||||
return { ok: false, error: "login result" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(loginOptions).toEqual({
|
||||
embedded: true,
|
||||
defaultApiUrl: "https://example.trigger.dev",
|
||||
profile: "ci",
|
||||
silent: false,
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: "login result" });
|
||||
});
|
||||
|
||||
it("throws a descriptive error for an invalid API URL", async () => {
|
||||
await expect(
|
||||
authenticateForDeploy({
|
||||
accessToken: "tr_prod_sk_deploy",
|
||||
apiUrl: "not-a-url",
|
||||
profile: "default",
|
||||
silent: true,
|
||||
login: async () => ({ ok: false, error: "should not be called" }),
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Invalid API URL "not-a-url". Check your TRIGGER_API_URL environment variable or --api-url flag.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userIdForDeploy", () => {
|
||||
it("omits user attribution for API-key deployments", () => {
|
||||
expect(
|
||||
userIdForDeploy({
|
||||
ok: true,
|
||||
profile: "default",
|
||||
dashboardUrl: "https://cloud.trigger.dev",
|
||||
auth: {
|
||||
apiUrl: "https://api.trigger.dev",
|
||||
accessToken: "tr_prod_sk_deploy",
|
||||
tokenType: "apiKey",
|
||||
},
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { CLOUD_API_URL, CLOUD_WEB_URL } from "../consts.js";
|
||||
import { readAuthConfigProfile } from "../utilities/configFiles.js";
|
||||
import type { LoginResult, LoginResultOk } from "../utilities/session.js";
|
||||
|
||||
const personalTokenPrefix = "tr_pat_";
|
||||
const organizationTokenPrefix = "tr_oat_";
|
||||
const apiKeyPrefix = "tr_";
|
||||
|
||||
export type DeployAuthorization =
|
||||
| LoginResultOk
|
||||
| {
|
||||
ok: true;
|
||||
profile: string;
|
||||
dashboardUrl: string;
|
||||
auth: {
|
||||
apiUrl: string;
|
||||
accessToken: string;
|
||||
tokenType: "apiKey";
|
||||
};
|
||||
};
|
||||
|
||||
type LoginForDeploy = (options: {
|
||||
embedded: true;
|
||||
defaultApiUrl: string;
|
||||
profile: string;
|
||||
silent: boolean;
|
||||
}) => Promise<LoginResult>;
|
||||
|
||||
export async function authenticateForDeploy({
|
||||
accessToken,
|
||||
apiUrl,
|
||||
profile,
|
||||
silent,
|
||||
login,
|
||||
}: {
|
||||
accessToken?: string;
|
||||
apiUrl?: string;
|
||||
profile: string;
|
||||
silent: boolean;
|
||||
login: LoginForDeploy;
|
||||
}): Promise<LoginResult | DeployAuthorization> {
|
||||
const authConfig = readAuthConfigProfile(profile);
|
||||
const resolvedApiUrl = apiUrl ?? authConfig?.apiUrl ?? CLOUD_API_URL;
|
||||
|
||||
const isApiKey =
|
||||
!!accessToken &&
|
||||
accessToken.startsWith(apiKeyPrefix) &&
|
||||
!accessToken.startsWith(personalTokenPrefix) &&
|
||||
!accessToken.startsWith(organizationTokenPrefix);
|
||||
|
||||
if (!isApiKey) {
|
||||
return login({
|
||||
embedded: true,
|
||||
defaultApiUrl: resolvedApiUrl,
|
||||
profile,
|
||||
silent,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
profile,
|
||||
dashboardUrl: dashboardUrlForApiUrl(resolvedApiUrl),
|
||||
auth: {
|
||||
apiUrl: resolvedApiUrl,
|
||||
accessToken,
|
||||
tokenType: "apiKey",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardUrlForApiUrl(apiUrl: string): string {
|
||||
if (apiUrl === CLOUD_API_URL) {
|
||||
return CLOUD_WEB_URL;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.hostname.startsWith("api.") && url.hostname.endsWith(".trigger.dev")) {
|
||||
url.hostname = url.hostname.slice(4);
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Invalid API URL "${apiUrl}". Check your TRIGGER_API_URL environment variable or --api-url flag.`
|
||||
);
|
||||
}
|
||||
|
||||
return apiUrl;
|
||||
}
|
||||
|
||||
export function userIdForDeploy(authorization: DeployAuthorization): string | undefined {
|
||||
return authorization.auth.tokenType === "personal" && "userId" in authorization
|
||||
? authorization.userId
|
||||
: undefined;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export type {
|
||||
AuthenticatedEnvironment,
|
||||
RbacScopeAction,
|
||||
RbacScopeResourceType,
|
||||
BearerAuthOptions,
|
||||
} from "./rbac.js";
|
||||
|
||||
export {
|
||||
|
||||
@@ -198,6 +198,7 @@ export type RbacScopeResourceType =
|
||||
| "deployments"
|
||||
| "envvars"
|
||||
| "apiKeys"
|
||||
| "branches"
|
||||
| "sessions"
|
||||
| "waitpoints"
|
||||
| "tags"
|
||||
@@ -381,6 +382,13 @@ export type BearerAuthResult =
|
||||
jwt?: { realtime?: { skipColumns?: string[] }; oneTimeUse?: boolean; act?: { sub: string } };
|
||||
};
|
||||
|
||||
export type BearerAuthOptions = {
|
||||
allowJWT?: boolean;
|
||||
// Branch creation authenticates against the branchable Preview parent before
|
||||
// the requested child environment exists.
|
||||
allowPreviewParent?: boolean;
|
||||
};
|
||||
|
||||
export type SessionAuthResult =
|
||||
| { ok: false; reason: "unauthenticated" | "unauthorized" }
|
||||
| { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility };
|
||||
@@ -433,7 +441,7 @@ export interface RoleBaseAccessController {
|
||||
|
||||
// API routes (Bearer token): one DB query → identity + pre-built ability
|
||||
// options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys
|
||||
authenticateBearer(request: Request, options?: { allowJWT?: boolean }): Promise<BearerAuthResult>;
|
||||
authenticateBearer(request: Request, options?: BearerAuthOptions): Promise<BearerAuthResult>;
|
||||
|
||||
// Dashboard loaders/actions (session cookie): one DB query → user + pre-built ability.
|
||||
// The caller resolves `userId` from the session cookie and passes it in.
|
||||
@@ -481,7 +489,7 @@ export interface RoleBaseAccessController {
|
||||
authenticateAuthorizeBearer(
|
||||
request: Request,
|
||||
check: { action: string; resource: RbacResource | RbacResource[] },
|
||||
options?: { allowJWT?: boolean }
|
||||
options?: BearerAuthOptions
|
||||
): Promise<BearerAuthResult>;
|
||||
|
||||
authenticateAuthorizeSession(
|
||||
|
||||
Reference in New Issue
Block a user