Files
triggerdotdev--trigger.dev/apps/webapp/app/models/runtimeEnvironment.server.ts
Matt Aitken e4981d1b11 feat(webapp): consolidate auth path + add comprehensive auth tests (#3499)
## Summary

Consolidates the webapp's authentication and authorization into a small
set of route helpers, replacing the ad-hoc `requireUser` /
`requireUserId` / `authenticatedEnvironmentForAuthentication` calls
scattered across routes. Same security model, but the per-request flow
(authenticate → authorize → load) now lives in one place per route
family.

Introduces a plugin seam (`@trigger.dev/plugins`) that lets the cloud
build install a richer RBAC implementation without touching webapp code.
The OSS fallback keeps the pre-RBAC permissive behaviour intact, so
self-hosted deployments work unchanged.

Adds a comprehensive end-to-end auth test suite that didn't exist before
— 193 `it()` blocks (vitest reports ~199 after `it.each` expansion)
covering API key, PAT and JWT auth across the public API surface, plus
dashboard session auth for admin pages.

## Changes

### Plugin contract — `@trigger.dev/plugins`

`RoleBaseAccessController` interface authoritative for both OSS
(fallback) and cloud (enterprise plugin):
- `authenticateBearer(request, { allowJWT? })` — API-key / public-JWT
auth, returns env + ability
- `authenticateSession(request, { userId, organizationId?, projectId?
})` — dashboard auth, caller resolves `userId` from the session cookie
and passes it in (no `helpers.getSessionUserId` callback — decouples the
plugin host from session-cookie code)
- `authenticatePat(request, { organizationId?, projectId? })` — PAT
auth, returns identity + `lastAccessedAt` so the host can throttle the
per-request update
- `authenticateAuthorize*` variants for the auth-and-check-in-one-call
cases
- `isUsingPlugin(): Promise<boolean>` — capability flag for UI /
branching where plugin-present-ness matters; replaces the
sentinel-string coupling that had `personalAccessToken.server` matching
`"RBAC plugin not installed"` literally

### Dashboard auth (started, partial rollout)

Admin and settings pages migrated to a unified `dashboardLoader` /
`dashboardAction` helper that authenticates the session, runs an
authorization check, and exposes the result to the route. Other
dashboard routes still on the old pattern; remaining migration tracked
in TRI-8730.

Migrated routes:
- `admin.*` (14 admin / back-office / feature-flags / LLM-models /
notifications / orgs / concurrency pages)
- `_app.orgs.$organizationSlug.settings.team`
- `_app.orgs.$organizationSlug.settings.roles`

### API / realtime / engine auth (complete for the migrated families)

71 routes migrated to a unified `apiBuilder` that centralizes Bearer /
PAT / Public-JWT authentication and applies the per-route authorization
check before the handler runs. Includes:
- `api.v1.*` and `api.v2.*` and `api.v3.*` — tasks, runs, batches,
queues, prompts, deployments, query, sessions, waitpoints, packets,
workers, idempotency keys
- `realtime.v1.*` — runs, batches, sessions, streams
- `engine.v1.*` — dev / worker-action protocols

29 routes still on the legacy `authenticateApiRequest*` helpers —
tracked as a post-deploy follow-up in TRI-9228.

Multi-resource auth direction is now explicit at the call site via
`anyResource(...)` (OR) and `everyResource(...)` (AND). Bare arrays no
longer typecheck — fixes a class of bug where a JWT scoped to one
resource could implicitly access others under OR semantics.

PAT auth path consolidated: was three DB queries per request (legacy
`authenticateApiRequestWithPersonalAccessToken` findFirst +
`rbac.authenticatePat` join + `lastAccessedAt` update). Now one query in
the steady state — plugin returns `lastAccessedAt`, host smart-skips the
update via JS-side throttle when fresh.

Side effect: action aliases preserved historic JWT scope semantics where
the new model is stricter (e.g. a `write:tasks` JWT now also satisfies
`trigger` / `batchTrigger` / `update` actions on the same resource —
matched at the auth boundary, not in the route handler).

### Backwards-compat fixes

The strict-match model regressed several real-world JWT shapes. Each
preserved via explicit `anyResource(...)` entries in the route's authz
block:

- **Batch retrieve routes** (`api.v1.batches.$batchId`, `api.v2.*`,
`realtime.v1.batches.*`) accept `read:runs` JWTs again (pre-RBAC
literal-match superScope behaviour)
- **Runs list routes** (`api.v1.runs`, `realtime.v1.runs`) accept
type-level `read:tasks` / `read:tags` on unfiltered queries (matched the
legacy `Object.keys` iteration semantic)
- **PAT/OAT auth shape** normalized through `toAuthenticated` so all
auth methods return the same slim `AuthenticatedEnvironment` (was:
API-key returned the slim shape but PAT/OAT returned raw Prisma
`Decimal` / no `orgMember`)
- **Scope `:` preservation** in resource ids — `read:tags:env:staging`
now correctly identifies the tag id as `env:staging`, not `env`

### Slim `AuthenticatedEnvironment`

Extracted to `@trigger.dev/core/v3/auth/environment` — a structural
shape independent of `@trigger.dev/database`. The plugin contract
returns this; webapp consumers import from there; the cloud plugin
(Drizzle) returns the same shape without Prisma's `Decimal` class
leaking into the public surface. Lets internal-packages (run-engine,
etc.) refer to `AuthenticatedEnvironment` without pulling Prisma in.

### Auth test suite (new — `*.e2e.full.test.ts`)

193 e2e tests run against a real spawned webapp + Postgres (no mocks).
Coverage matrix:

- **API key auth** — read / write / trigger / batchTrigger / deploy
actions across runs, batches, deployments, prompts, queues, query,
sessions, input-streams, waitpoints, tasks, idempotency keys; multi-key
resources (a run carries batch / tag / task identifiers — auth must
accept any matching scope)
- **Personal Access Token auth** — comprehensive matrix: scope match,
scope mismatch, missing scope, expired token, malformed token
- **Public JWT auth** — sub-vs-URL environment resolution, expired JWTs,
signature verification, scope checking, otu (one-time-use) token
semantics, branch-environment signing-key fallback
- **Dashboard session auth** — admin-only pages reject non-admins;
per-action gating
- **Cross-cutting edge cases** — revoked API key grace window, JWT
cross-environment isolation, MissingResource branch behaviour

### Hygiene cleanups

- Deleted dead `app/services/authorization.server.ts` (legacy
`checkAuthorization` + types — no live consumers post-migration) and its
orphaned test
- Dropped the never-populated `scopes` field from
`ApiAuthenticationResultSuccess`
- `scheduleEmail` moved out of `email.server.ts` into its own module —
breaks a `commonWorker → marqs/V1` import chain that was poisoning the
auth test graph
- OSS Roles page shows a deployment-aware empty state ("Roles aren't
available in this self-hosted deployment" vs the plan-upsell copy) via
`rbac.isUsingPlugin()`
- Team action handler: explicit per-intent ability gates
(`manage:billing` for purchase-seats, `manage:members` for set-role +
remove-member with self-leave carve-out)

### Cross-repo coordination

All public-package contract changes paired in `triggerdotdev/cloud#763`
(rbac-packages branch) — the enterprise plugin implements the same
`RoleBaseAccessController` interface against Drizzle.

## Test plan

- [x] `pnpm run typecheck --filter webapp` clean
- [x] `pnpm --filter webapp exec vitest run --config
vitest.e2e.full.config.ts` — 193/193 pass (requires Docker for
testcontainers)
- [x] Spot-check an authed API endpoint with a valid + invalid API key
against a local stack
- [x] Spot-check the migrated admin pages render and gate non-admins

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:16:20 +01:00

419 lines
11 KiB
TypeScript

import type { AuthenticatedEnvironment } from "@internal/run-engine";
import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@trigger.dev/database";
import { $replica, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { getUsername } from "~/utils/username";
import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
export type { RuntimeEnvironment };
// Prisma include shape that maps cleanly to the slim AuthenticatedEnvironment.
// Use this everywhere we fetch an env that flows to handlers — keeps the
// returned shape consistent (and the Decimal coercion in toAuthenticated()
// strips Prisma's Decimal class from the public surface).
export const authIncludeBase = {
project: true,
organization: true,
orgMember: {
select: {
userId: true,
user: { select: { id: true, displayName: true, name: true } },
},
},
} satisfies Prisma.RuntimeEnvironmentInclude;
export const authIncludeWithParent = {
...authIncludeBase,
parentEnvironment: { select: { id: true, apiKey: true } },
} satisfies Prisma.RuntimeEnvironmentInclude;
type PrismaEnvWithAuth = Prisma.RuntimeEnvironmentGetPayload<{ include: typeof authIncludeBase }>;
type PrismaEnvWithAuthAndParent = Prisma.RuntimeEnvironmentGetPayload<{
include: typeof authIncludeWithParent;
}>;
// Coerce a Prisma RuntimeEnvironment payload to the slim
// AuthenticatedEnvironment shape. Drops the columns handlers don't read
// and converts `concurrencyLimitBurstFactor` from Prisma's Decimal to a
// plain number (lossless at this scale). The optional union accepts both
// query shapes — with parentEnvironment loaded, or without it.
export function toAuthenticated(
env: PrismaEnvWithAuth | PrismaEnvWithAuthAndParent,
): AuthenticatedEnvironment {
return {
id: env.id,
slug: env.slug,
type: env.type,
apiKey: env.apiKey,
organizationId: env.organizationId,
projectId: env.projectId,
orgMemberId: env.orgMemberId,
parentEnvironmentId: env.parentEnvironmentId,
branchName: env.branchName,
archivedAt: env.archivedAt,
paused: env.paused,
shortcode: env.shortcode,
maximumConcurrencyLimit: env.maximumConcurrencyLimit,
// Coerce Prisma's Decimal to a plain number — the slim type accepts
// both, but downstream consumers shouldn't have to narrow before
// doing arithmetic. Lossless at this scale (Decimal(4,2)).
concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor.toNumber(),
builtInEnvironmentVariableOverrides: env.builtInEnvironmentVariableOverrides,
createdAt: env.createdAt,
updatedAt: env.updatedAt,
project: {
id: env.project.id,
slug: env.project.slug,
name: env.project.name,
externalRef: env.project.externalRef,
engine: env.project.engine,
deletedAt: env.project.deletedAt,
defaultWorkerGroupId: env.project.defaultWorkerGroupId,
organizationId: env.project.organizationId,
builderProjectId: env.project.builderProjectId,
},
organization: {
id: env.organization.id,
slug: env.organization.slug,
title: env.organization.title,
streamBasinName: env.organization.streamBasinName,
maximumConcurrencyLimit: env.organization.maximumConcurrencyLimit,
runsEnabled: env.organization.runsEnabled,
maximumDevQueueSize: env.organization.maximumDevQueueSize,
maximumDeployedQueueSize: env.organization.maximumDeployedQueueSize,
featureFlags: env.organization.featureFlags,
apiRateLimiterConfig: env.organization.apiRateLimiterConfig,
batchRateLimitConfig: env.organization.batchRateLimitConfig,
batchQueueConcurrencyConfig: env.organization.batchQueueConcurrencyConfig,
},
orgMember: env.orgMember,
parentEnvironment: "parentEnvironment" in env ? env.parentEnvironment : null,
};
}
export async function findEnvironmentByApiKey(
apiKey: string,
branchName: string | undefined
): Promise<AuthenticatedEnvironment | null> {
const include = {
...authIncludeBase,
childEnvironments: branchName
? {
where: {
branchName: sanitizeBranchName(branchName),
archivedAt: null,
},
}
: undefined,
} satisfies Prisma.RuntimeEnvironmentInclude;
let environment = await $replica.runtimeEnvironment.findFirst({
where: {
apiKey,
},
include,
});
// Fall back to keys that were revoked within the grace window
if (!environment) {
const revokedApiKey = await $replica.revokedApiKey.findFirst({
where: {
apiKey,
expiresAt: { gt: new Date() },
},
include: {
runtimeEnvironment: { include },
},
});
environment = revokedApiKey?.runtimeEnvironment ?? null;
}
if (!environment) {
return null;
}
//don't return deleted projects
if (environment.project.deletedAt !== null) {
return null;
}
if (environment.type === "PREVIEW") {
if (!branchName) {
logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", {
environmentId: environment.id,
});
return null;
}
const childEnvironment = environment.childEnvironments.at(0);
if (childEnvironment) {
return 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 toAuthenticated(environment);
}
/**
* @deprecated We don't use public API keys (`pk_*` tokens) anymore — public
* access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`).
*
* Still exported because a handful of pre-RBAC routes that haven't been
* migrated to the apiBuilder still wire this lookup into their
* `authenticateApiKey` / `authenticateApiKeyWithFailure` flow. The new RBAC
* fallback (`internal-packages/rbac/src/fallback.ts`) intentionally does NOT
* call this — any pk_*-authenticated request that hits an apiBuilder route
* returns 401. That's a deliberate cutover, not an oversight.
*/
export async function findEnvironmentByPublicApiKey(
apiKey: string,
branchName: string | undefined
): Promise<AuthenticatedEnvironment | null> {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
pkApiKey: apiKey,
},
include: authIncludeBase,
});
if (!environment || environment.project.deletedAt !== null) {
return null;
}
return toAuthenticated(environment);
}
export async function findEnvironmentById(id: string): Promise<AuthenticatedEnvironment | null> {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
id,
},
include: authIncludeWithParent,
});
if (!environment || environment.project.deletedAt !== null) {
return null;
}
return toAuthenticated(environment);
}
export async function findEnvironmentBySlug(
projectId: string,
envSlug: string,
userId: string
): Promise<AuthenticatedEnvironment | null> {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: projectId,
slug: envSlug,
OR: [
{
type: {
in: ["PREVIEW", "STAGING", "PRODUCTION"],
},
},
{
type: "DEVELOPMENT",
orgMember: {
userId,
},
},
],
},
include: authIncludeBase,
});
return environment ? toAuthenticated(environment) : null;
}
export async function findEnvironmentFromRun(
runId: string,
tx?: PrismaClientOrTransaction
): Promise<AuthenticatedEnvironment | null> {
const taskRun = await (tx ?? $replica).taskRun.findFirst({
where: {
id: runId,
},
include: {
runtimeEnvironment: { include: authIncludeBase },
},
});
return taskRun?.runtimeEnvironment ? toAuthenticated(taskRun.runtimeEnvironment) : null;
}
export async function createNewSession(
environment: Pick<RuntimeEnvironment, "id">,
ipAddress: string
) {
const session = await prisma.runtimeEnvironmentSession.create({
data: {
environmentId: environment.id,
ipAddress,
},
});
await prisma.runtimeEnvironment.update({
where: {
id: environment.id,
},
data: {
currentSessionId: session.id,
},
});
return session;
}
export async function disconnectSession(environmentId: string) {
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
id: environmentId,
},
});
if (!environment || !environment.currentSessionId) {
return null;
}
const session = await prisma.runtimeEnvironmentSession.update({
where: {
id: environment.currentSessionId,
},
data: {
disconnectedAt: new Date(),
},
});
await prisma.runtimeEnvironment.update({
where: {
id: environment.id,
},
data: {
currentSessionId: null,
},
});
return session;
}
export async function findLatestSession(environmentId: string) {
const session = await $replica.runtimeEnvironmentSession.findFirst({
where: {
environmentId,
},
orderBy: {
createdAt: "desc",
},
});
return session;
}
export type DisplayableInputEnvironment = Prisma.RuntimeEnvironmentGetPayload<{
select: {
id: true;
type: true;
slug: true;
orgMember: {
select: {
user: {
select: {
id: true;
name: true;
displayName: true;
};
};
};
};
};
}>;
export function displayableEnvironment(
environment: DisplayableInputEnvironment,
userId: string | undefined
) {
let userName: string | undefined = undefined;
if (environment.type === "DEVELOPMENT") {
if (!environment.orgMember) {
userName = "Deleted";
} else if (environment.orgMember.user.id !== userId) {
userName = getUsername(environment.orgMember.user);
}
}
return {
id: environment.id,
type: environment.type,
slug: environment.slug,
userName,
};
}
export async function findDisplayableEnvironment(
environmentId: string,
userId: string | undefined
) {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
id: environmentId,
},
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
});
if (!environment) {
return;
}
return displayableEnvironment(environment, userId);
}
export async function hasAccessToEnvironment({
environmentId,
projectId,
organizationId,
userId,
}: {
environmentId: string;
projectId: string;
organizationId: string;
userId: string;
}): Promise<boolean> {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
id: environmentId,
projectId: projectId,
organizationId: organizationId,
organization: { members: { some: { userId } } },
},
});
return environment !== null;
}