Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 133 additions & 33 deletions apps/webapp/app/models/runtimeEnvironment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { logger } from "~/services/logger.server";
import { getUsername } from "~/utils/username";
import { hashApiKey } from "~/utils/apiKeys";
import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import { scopesGrantFullAccess } from "@trigger.dev/rbac";

export type { RuntimeEnvironment };

Expand Down Expand Up @@ -93,11 +96,21 @@ export function toAuthenticated(
};
}

export async function findEnvironmentByApiKey(
export type ApiKeyEnvironmentResolution =
| { ok: true; environment: AuthenticatedEnvironment }
| { ok: false; reason: "not-found" | "restricted" };

/**
* Resolve an environment from a raw API key for legacy routes that do not
* declare authorization. Additional keys are accepted only when their stored
* scopes explicitly grant full access; restricted keys fail closed here
* (`reason: "restricted"`, so callers can explain the rejection).
*/
async function resolveEnvironmentByApiKey(
apiKey: string,
branchName: string | undefined,
tx: PrismaClientOrTransaction = $replica
): Promise<AuthenticatedEnvironment | null> {
tx: PrismaClientOrTransaction
): Promise<ApiKeyEnvironmentResolution> {
const branch = sanitizeBranchName(branchName) ?? undefined;

const include = {
Expand All @@ -112,80 +125,167 @@ export async function findEnvironmentByApiKey(
: undefined,
} satisfies Prisma.RuntimeEnvironmentInclude;

let environment = await tx.runtimeEnvironment.findFirst({
where: {
apiKey,
},
include,
});
const now = new Date();
const routesToAdditionalKey = isAdditionalApiKey(apiKey);
let rootEnvironment = routesToAdditionalKey
? null
: await tx.runtimeEnvironment.findFirst({
where: {
apiKey,
},
include,
});

// Fall back to keys that were revoked within the grace window
if (!environment) {
// Fall back to root keys that were rotated within the grace window.
if (!routesToAdditionalKey && !rootEnvironment) {
const revokedApiKey = await tx.revokedApiKey.findFirst({
where: {
apiKey,
expiresAt: { gt: new Date() },
expiresAt: { gt: now },
},
include: {
runtimeEnvironment: { include },
},
});

environment = revokedApiKey?.runtimeEnvironment ?? null;
rootEnvironment = revokedApiKey?.runtimeEnvironment ?? null;
}

// Additional keys are host-owned credentials. Legacy routes cannot apply a
// scoped ability, so only an explicit full-access scope is accepted.
const match = routesToAdditionalKey
? await tx.apiKey.findFirst({
where: {
keyHash: hashApiKey(apiKey),
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: {
id: true,
lastUsedAt: true,
scopes: true,
runtimeEnvironment: { include },
},
})
: null;

if (match && !scopesGrantFullAccess(match.scopes)) {
return { ok: false, reason: "restricted" };
}

const additionalApiKey = match ? { id: match.id, lastUsedAt: match.lastUsedAt } : null;
let environment = rootEnvironment ?? match?.runtimeEnvironment ?? null;

if (!environment) {
return null;
return { ok: false, reason: "not-found" };
}

if (
additionalApiKey &&
(!additionalApiKey.lastUsedAt ||
additionalApiKey.lastUsedAt < new Date(now.getTime() - 300_000))
) {
try {
// Deliberately the primary `prisma`, not `tx`: `tx` defaults to the
// read replica (and may be a caller's transaction), and this last-used
// telemetry write must hit the writer. It's throttled to once every 5
// minutes per key and best-effort — auth never fails if it can't record.
await prisma.apiKey.updateMany({
where: {
id: additionalApiKey.id,
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
data: { lastUsedAt: now },
});
} catch (error) {
logger.warn("Failed to update API key last-used timestamp", {
apiKeyId: additionalApiKey.id,
error,
});
}
}

//don't return deleted projects
if (environment.project.deletedAt !== null) {
return null;
return { ok: false, reason: "not-found" };
}

if (environment.type === "PREVIEW") {
if (!branch) {
logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", {
environmentId: environment.id,
});
return null;
return { ok: false, reason: "not-found" };
}

const childEnvironment = environment.childEnvironments.at(0);

if (childEnvironment) {
return toAuthenticated({
...childEnvironment,
apiKey: environment.apiKey,
orgMember: environment.orgMember,
organization: environment.organization,
project: environment.project,
});
return {
ok: true,
environment: toAuthenticated({
...childEnvironment,
apiKey: environment.apiKey,
orgMember: environment.orgMember,
organization: environment.organization,
project: environment.project,
}),
};
}

//A branch was specified but no child environment was found
return null;
return { ok: false, reason: "not-found" };
}

// If there is a named DEV branch (other than default), return it
if (environment.type === "DEVELOPMENT" && branch !== undefined && !isDefaultDevBranch(branch)) {
const childEnvironment = environment.childEnvironments.at(0);

if (childEnvironment) {
return toAuthenticated({
...childEnvironment,
apiKey: environment.apiKey,
orgMember: environment.orgMember,
organization: environment.organization,
project: environment.project,
});
return {
ok: true,
environment: toAuthenticated({
...childEnvironment,
apiKey: environment.apiKey,
orgMember: environment.orgMember,
organization: environment.organization,
project: environment.project,
}),
};
}

//A branch was specified but no child environment was found
return null;
return { ok: false, reason: "not-found" };
}

return toAuthenticated(environment);
return { ok: true, environment: toAuthenticated(environment) };
}

/**
* Resolve an environment from a raw API key. Root and grace-window keys keep
* their legacy behavior; additional keys with restricted scopes fail closed.
*/
export async function findEnvironmentByApiKey(
apiKey: string,
branchName: string | undefined,
tx: PrismaClientOrTransaction = $replica
): Promise<AuthenticatedEnvironment | null> {
const resolution = await resolveEnvironmentByApiKey(apiKey, branchName, tx);
return resolution.ok ? resolution.environment : null;
}

/**
* Like `findEnvironmentByApiKey`, but distinguishes a restricted additional
* key (fails closed on legacy routes) from an unknown key so callers can
* return an accurate error message.
*/
export async function findEnvironmentByApiKeyWithResolution(
apiKey: string,
branchName: string | undefined,
tx: PrismaClientOrTransaction = $replica
): Promise<ApiKeyEnvironmentResolution> {
return resolveEnvironmentByApiKey(apiKey, branchName, tx);
}

/**
Expand Down
39 changes: 31 additions & 8 deletions apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { getUserProvidedIdempotencyKey } from "@trigger.dev/core/v3/serverOnly";
import type { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
import type { RbacAbility } from "@trigger.dev/rbac";
import assertNever from "assert-never";
import type { API_VERSIONS, RunStatusUnspecifiedApiVersion } from "~/api/versions";
import { CURRENT_API_VERSION } from "~/api/versions";
Expand Down Expand Up @@ -83,6 +84,24 @@ type CommonRelatedRunWithVersion = CommonRelatedRun & {
// ReturnType<typeof findRun>) so findRun can return a synthesised buffered
// run without the type becoming self-referential. Exported so the
// buffer-synthesis helper below can match this shape under unit test.
function canReadRelatedRun(
ability: RbacAbility | undefined,
run: CommonRelatedRunWithVersion
): boolean {
if (!ability) return true;

const resources = [
{ type: "runs", id: run.friendlyId },
{ type: "tasks", id: run.taskIdentifier },
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
];
if (run.batch?.friendlyId) {
resources.push({ type: "batch", id: run.batch.friendlyId });
}

return ability.can("read", resources);
}

export type FoundRun = CommonRelatedRunWithVersion & {
traceId: string;
payload: string;
Expand Down Expand Up @@ -212,7 +231,7 @@ export class ApiRetrieveRunPresenter {
return synthesiseFoundRunFromBuffer(buffered);
}

public async call(taskRun: FoundRun, env: AuthenticatedEnvironment) {
public async call(taskRun: FoundRun, env: AuthenticatedEnvironment, ability?: RbacAbility) {
return startSpanWithEnv(tracer, "ApiRetrieveRunPresenter.call", env, async () => {
let $payload: any;
let $payloadPresignedUrl: string | undefined;
Expand Down Expand Up @@ -290,14 +309,18 @@ export class ApiRetrieveRunPresenter {
taskRun.engine === "V1" ? taskRun.attempts.length : (taskRun.attemptNumber ?? 0),
attempts: [],
relatedRuns: {
root: taskRun.rootTaskRun
? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion)
: undefined,
parent: taskRun.parentTaskRun
? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion)
: undefined,
root:
taskRun.rootTaskRun && canReadRelatedRun(ability, taskRun.rootTaskRun)
? await createCommonRunStructure(taskRun.rootTaskRun, this.apiVersion)
: undefined,
parent:
taskRun.parentTaskRun && canReadRelatedRun(ability, taskRun.parentTaskRun)
? await createCommonRunStructure(taskRun.parentTaskRun, this.apiVersion)
: undefined,
children: await Promise.all(
taskRun.childRuns.map(async (r) => await createCommonRunStructure(r, this.apiVersion))
taskRun.childRuns
.filter((run) => canReadRelatedRun(ability, run))
.map(async (run) => await createCommonRunStructure(run, this.apiVersion))
),
},
};
Expand Down
16 changes: 9 additions & 7 deletions apps/webapp/app/routes/api.v1.artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
CreateArtifactRequestBody,
tryCatch,
} from "@trigger.dev/core/v3";
import { authenticateRequest } from "~/services/apiAuth.server";
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { ArtifactsService } from "~/v3/services/artifacts.server";

Expand All @@ -14,17 +14,19 @@ export async function action({ request }: ActionFunctionArgs) {
}

try {
const authenticationResult = await authenticateRequest(request, {
apiKey: true,
organizationAccessToken: false,
personalAccessToken: false,
// Artifact uploads are part of the deploy flow (deployment context archive).
const authResult = await authenticateApiKeyWithScope(request, {
action: "write",
resource: { type: "deployments" },
});

if (!authenticationResult || !authenticationResult.result.ok) {
if (!authResult.ok) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
return json({ error: authResult.error }, { status: authResult.status });
}

const authenticationResult = { result: authResult.authentication };

const [, rawBody] = await tryCatch(request.json());
const body = CreateArtifactRequestBody.safeParse(rawBody ?? {});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { CreateDeclarativeScheduleError } from "~/v3/services/createBackgroundWorker.server";
Expand All @@ -26,13 +26,18 @@ export async function action({ request, params }: ActionFunctionArgs) {

try {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
const authResult = await authenticateApiKeyWithScope(request, {
action: "write",
resource: { type: "deployments" },
});

if (!authenticationResult) {
if (!authResult.ok) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
return json({ error: authResult.error }, { status: authResult.status });
}

const authenticationResult = authResult.authentication;

const authenticatedEnv = authenticationResult.environment;

const { deploymentId } = parsedParams.data;
Expand Down
Loading
Loading