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>
This commit is contained in:
Matt Aitken
2026-05-12 17:16:20 +01:00
committed by GitHub
parent 3cbe9f2307
commit e4981d1b11
131 changed files with 8902 additions and 1714 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/plugins": patch
---
The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces.
+120
View File
@@ -0,0 +1,120 @@
name: "🛡️ E2E Tests: Webapp Auth (full)"
# Comprehensive RBAC auth test suite — see TRI-8731. Runs separately from
# the smoke e2e-webapp.yml because it covers every route family with a
# pass/fail matrix and would otherwise dominate per-PR CI time.
#
# Triggered:
# - Manually via workflow_dispatch.
# - Nightly via schedule.
# - On pull requests touching auth-relevant files only (paths filter).
permissions:
contents: read
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # 04:00 UTC daily
pull_request:
paths:
- "apps/webapp/app/services/routeBuilders/**"
- "apps/webapp/app/services/rbac.server.ts"
- "apps/webapp/app/services/apiAuth.server.ts"
- "apps/webapp/app/services/personalAccessToken.server.ts"
- "apps/webapp/app/services/sessionStorage.server.ts"
- "apps/webapp/app/routes/api.v*.**"
- "apps/webapp/app/routes/realtime.v*.**"
- "apps/webapp/test/**/*.e2e.full.test.ts"
- "apps/webapp/test/setup/global-e2e-full-setup.ts"
- "apps/webapp/test/helpers/sharedTestServer.ts"
- "apps/webapp/test/helpers/seedTestSession.ts"
- "apps/webapp/vitest.e2e.full.config.ts"
- "internal-packages/rbac/**"
- "packages/plugins/**"
- ".github/workflows/e2e-webapp-auth-full.yml"
jobs:
e2eAuthFull:
name: "🛡️ E2E Auth Tests (full)"
runs-on: ubuntu-latest
timeout-minutes: 30
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
steps:
- name: 🔧 Disable IPv6
run: |
sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1
sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1
- name: 🔧 Configure docker address pool
run: |
CONFIG='{
"default-address-pools" : [
{
"base" : "172.17.0.0/12",
"size" : 20
},
{
"base" : "192.168.0.0/16",
"size" : 24
}
]
}'
mkdir -p /etc/docker
echo "$CONFIG" | sudo tee /etc/docker/daemon.json
- name: 🔧 Restart docker daemon
run: sudo systemctl restart docker
- name: ⬇️ Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
# Don't leave the GITHUB_TOKEN in .git/config — this job
# doesn't need to push and the persisted creds would be
# readable from any subsequent step (zizmor/artipacked).
persist-credentials: false
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.33.2
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.20.0
cache: "pnpm"
- name: 🐳 Login to DockerHub
if: ${{ env.DOCKERHUB_USERNAME }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: 🐳 Skipping DockerHub login (no secrets available)
if: ${{ !env.DOCKERHUB_USERNAME }}
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
run: |
docker pull postgres:14
docker pull redis:7.2
docker pull testcontainers/ryuk:0.11.0
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🏗️ Build Webapp
run: pnpm run build --filter webapp
- name: 🛡️ Run Webapp Full Auth E2E Tests
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.full.config.ts --reporter=default
env:
WEBAPP_TEST_VERBOSE: "1"
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Webapp now supports a plugin system. Initially consolidates authentication and authorization paths.
@@ -4,6 +4,7 @@ import {
Cog8ToothIcon,
CreditCardIcon,
LockClosedIcon,
ShieldCheckIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
@@ -14,6 +15,7 @@ import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationRolesPath,
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationTeamPath,
@@ -45,9 +47,11 @@ export type BuildInfo = {
export function OrganizationSettingsSideMenu({
organization,
buildInfo,
isUsingPlugin,
}: {
organization: MatchedOrganization;
buildInfo: BuildInfo;
isUsingPlugin: boolean;
}) {
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
@@ -128,6 +132,16 @@ export function OrganizationSettingsSideMenu({
to={organizationTeamPath(organization)}
data-action="team"
/>
{isUsingPlugin && (
<SideMenuItem
name="Roles"
icon={ShieldCheckIcon}
activeIconColor="text-sky-500"
inactiveIconColor="text-sky-500"
to={organizationRolesPath(organization)}
data-action="roles"
/>
)}
<SideMenuItem
name="Settings"
icon={Cog8ToothIcon}
@@ -463,7 +463,15 @@ export function SelectItem({
...props
}: SelectItemProps) {
const combobox = Ariakit.useComboboxContext();
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
// In a Combobox context we wrap the caller's render in ComboboxItem
// so combobox keyboard nav still works. Outside a Combobox we pass
// the render through verbatim — without this, callers like
// SelectLinkItem (which uses render to swap in a <Link>) get their
// render prop silently dropped, which is why those rows looked
// clickable but didn't navigate.
const render = combobox
? <Ariakit.ComboboxItem render={props.render} />
: props.render;
const ref = React.useRef<HTMLDivElement>(null);
const select = Ariakit.useSelectContext();
const selectValue = select?.useState("value");
+3
View File
@@ -1542,6 +1542,9 @@ const EnvironmentSchema = z
// Private connections
PRIVATE_CONNECTIONS_ENABLED: z.string().optional(),
PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(),
// Force RBAC to not use the plugin
RBAC_FORCE_FALLBACK: BoolEnv.default(false),
})
.and(GithubAppEnvSchema)
.and(S2EnvSchema)
+38 -2
View File
@@ -1,6 +1,8 @@
import { type Prisma, prisma } from "~/db.server";
import { createEnvironment } from "./organization.server";
import { customAlphabet } from "nanoid";
import { logger } from "~/services/logger.server";
import { rbac } from "~/services/rbac.server";
const tokenValueLength = 40;
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
@@ -86,10 +88,19 @@ export async function inviteMembers({
slug,
emails,
userId,
rbacRoleId,
}: {
slug: string;
emails: string[];
userId: string;
/**
* Optional RBAC role to attach to the invite. When set, accepted
* invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember
* is created.
*
* `OrgMemberInvite.role` is still set if the plugin isn't installed.
*/
rbacRoleId?: string | null;
}) {
const org = await prisma.organization.findFirst({
where: { slug, members: { some: { userId } } },
@@ -107,6 +118,7 @@ export async function inviteMembers({
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
rbacRoleId: rbacRoleId ?? null,
} satisfies Prisma.OrgMemberInviteCreateManyInput)
);
@@ -163,7 +175,7 @@ export async function acceptInvite({
user: { id: string; email: string };
inviteId: string;
}) {
return await prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async (tx) => {
// 1. Delete the invite and get the invite details
const invite = await tx.orgMemberInvite.delete({
where: {
@@ -207,8 +219,32 @@ export async function acceptInvite({
},
});
return { remainingInvites, organization: invite.organization };
return {
remainingInvites,
organization: invite.organization,
inviteRole: invite.role,
rbacRoleId: invite.rbacRoleId,
};
});
// If the invite carried an explicit RBAC role. Errors are logged, not fatal.
if (result.rbacRoleId) {
const roleResult = await rbac.setUserRole({
userId: user.id,
organizationId: result.organization.id,
roleId: result.rbacRoleId,
});
if (!roleResult.ok) {
logger.error("acceptInvite: skipped RBAC role assignment", {
organizationId: result.organization.id,
userId: user.id,
rbacRoleId: result.rbacRoleId,
reason: roleResult.error,
});
}
}
return { remainingInvites: result.remainingInvites, organization: result.organization };
}
export async function declineInvite({
+1 -1
View File
@@ -4,7 +4,7 @@ import { $replica, prisma } from "~/db.server";
import type { Prisma, Project } from "@trigger.dev/database";
import { type Organization, createEnvironment } from "./organization.server";
import { env } from "~/env.server";
import { projectCreated } from "~/services/platform.v3.server";
import { projectCreated } from "~/services/projectCreated.server";
export type { Project } from "@trigger.dev/database";
const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20);
@@ -3,18 +3,100 @@ import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@tri
import { $replica, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { getUsername } from "~/utils/username";
import { sanitizeBranchName } from "~/v3/gitBranch";
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 = {
project: true,
organization: true,
orgMember: true,
...authIncludeBase,
childEnvironments: branchName
? {
where: {
@@ -67,23 +149,33 @@ export async function findEnvironmentByApiKey(
const childEnvironment = environment.childEnvironments.at(0);
if (childEnvironment) {
return {
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 environment;
return toAuthenticated(environment);
}
/** @deprecated We don't use public api keys anymore */
/**
* @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
@@ -92,50 +184,29 @@ export async function findEnvironmentByPublicApiKey(
where: {
pkApiKey: apiKey,
},
include: {
project: true,
organization: true,
orgMember: true,
},
include: authIncludeBase,
});
//don't return deleted projects
if (environment?.project.deletedAt !== null) {
if (!environment || environment.project.deletedAt !== null) {
return null;
}
return environment;
return toAuthenticated(environment);
}
export async function findEnvironmentById(
id: string
): Promise<
| (AuthenticatedEnvironment & { parentEnvironment: { id: string; apiKey: string } | null })
| null
> {
export async function findEnvironmentById(id: string): Promise<AuthenticatedEnvironment | null> {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
id,
},
include: {
project: true,
organization: true,
orgMember: true,
parentEnvironment: {
select: {
id: true,
apiKey: true,
},
},
},
include: authIncludeWithParent,
});
//don't return deleted projects
if (environment?.project.deletedAt !== null) {
if (!environment || environment.project.deletedAt !== null) {
return null;
}
return environment;
return toAuthenticated(environment);
}
export async function findEnvironmentBySlug(
@@ -143,7 +214,7 @@ export async function findEnvironmentBySlug(
envSlug: string,
userId: string
): Promise<AuthenticatedEnvironment | null> {
return $replica.runtimeEnvironment.findFirst({
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: projectId,
slug: envSlug,
@@ -161,12 +232,9 @@ export async function findEnvironmentBySlug(
},
],
},
include: {
project: true,
organization: true,
orgMember: true,
},
include: authIncludeBase,
});
return environment ? toAuthenticated(environment) : null;
}
export async function findEnvironmentFromRun(
@@ -178,24 +246,16 @@ export async function findEnvironmentFromRun(
id: runId,
},
include: {
runtimeEnvironment: {
include: {
project: true,
organization: true,
orgMember: true,
},
},
runtimeEnvironment: { include: authIncludeBase },
},
});
if (!taskRun) {
return null;
}
return taskRun?.runtimeEnvironment;
return taskRun?.runtimeEnvironment ? toAuthenticated(taskRun.runtimeEnvironment) : null;
}
export async function createNewSession(environment: RuntimeEnvironment, ipAddress: string) {
export async function createNewSession(
environment: Pick<RuntimeEnvironment, "id">,
ipAddress: string
) {
const session = await prisma.runtimeEnvironmentSession.create({
data: {
environmentId: environment.id,
@@ -1,4 +1,5 @@
import { getTeamMembersAndInvites } from "~/models/member.server";
import { rbac } from "~/services/rbac.server";
import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server";
import { BasePresenter } from "./v3/basePresenter.server";
@@ -13,11 +14,30 @@ export class TeamPresenter extends BasePresenter {
return;
}
const [baseLimit, currentPlan, plans] = await Promise.all([
getLimit(organizationId, "teamMembers", 100_000_000),
getCurrentPlan(organizationId),
getPlans(),
]);
const [baseLimit, currentPlan, plans, roles, assignableRoleIds, memberRoleMap] =
await Promise.all([
getLimit(organizationId, "teamMembers", 100_000_000),
getCurrentPlan(organizationId),
getPlans(),
// RBAC role catalogue (system roles + any org-defined custom
// roles). The default fallback returns []; an installed plugin
// may return the seeded system roles plus any custom roles.
rbac.allRoles(organizationId),
// Plan-gated subset — the Teams page disables dropdown options not
// in this set. Server-side enforcement is independent (setUserRole
// rejects a plan-gated assignment regardless of UI state).
rbac.getAssignableRoleIds(organizationId),
// Per-member current role in a single round-trip.
rbac.getUserRoles(
result.members.map((m) => m.user.id),
organizationId
),
]);
const memberRoles = result.members.map((m) => ({
userId: m.user.id,
role: memberRoleMap.get(m.user.id) ?? null,
}));
const canPurchaseSeats =
currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true;
@@ -38,6 +58,9 @@ export class TeamPresenter extends BasePresenter {
seatPricing,
maxSeatQuota,
planSeatLimit,
roles,
assignableRoleIds,
memberRoles,
};
}
}
@@ -149,10 +149,10 @@ type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
export class ApiRunListPresenter extends BasePresenter {
public async call(
project: Project,
project: Pick<Project, "id">,
searchParams: ApiRunListSearchParams,
apiVersion: API_VERSIONS,
environment?: RuntimeEnvironment
environment?: Pick<RuntimeEnvironment, "id" | "organizationId">
) {
return this.trace("call", async (span) => {
const options: RunListOptions = {
@@ -47,7 +47,10 @@ export class EnvironmentQueuePresenter extends BasePresenter {
running,
queued,
concurrencyLimit: environment.maximumConcurrencyLimit,
burstFactor: environment.concurrencyLimitBurstFactor.toNumber(),
burstFactor:
typeof environment.concurrencyLimitBurstFactor === "number"
? environment.concurrencyLimitBurstFactor
: environment.concurrencyLimitBurstFactor.toNumber(),
runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled,
queueSizeLimit,
};
@@ -25,13 +25,15 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { inviteMembers } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { scheduleEmail } from "~/services/email.server";
import { scheduleEmail } from "~/services/scheduleEmail.server";
import { rbac } from "~/services/rbac.server";
import { requireUserId } from "~/services/session.server";
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
@@ -63,9 +65,77 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Not Found", { status: 404 });
}
return typedjson(result);
// Inviter's own role drives the "below their level" filter on the
// dropdown. Plus assignable role IDs already encode the org's plan
// tier — the intersection is what we offer.
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
rbac.getUserRole({ userId, organizationId: organization.id }),
rbac.getAssignableRoleIds(organization.id),
rbac.systemRoles(organization.id),
]);
// Build the dropdown's offerable set server-side: roles that are
// (a) assignable on the current plan AND (b) at or below the
// inviter's own level. The client just renders these — it doesn't
// need to know about the system-role catalogue or the ladder.
const assignableSet = new Set(assignableRoleIds);
const offerableRoleIds = systemRoles
? result.roles
.filter(
(r) =>
assignableSet.has(r.id) &&
isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
)
.map((r) => r.id)
: [];
return typedjson({ ...result, offerableRoleIds });
};
// Sentinel for "no RBAC role attached to invite" — the runtime
// fallback will derive a role from the legacy OrgMember.role write at
// accept time. Used when the org has no RBAC plugin installed (the
// dropdown is hidden) or as a defensive default.
const NO_RBAC_ROLE = "__no_rbac_role__";
// An inviter can only assign a role at or below their own. The
// plugin's systemRoles array is in canonical order (highest authority
// first), so array index drives the ladder — earlier index = higher
// rank. Plan-tier filtering happens separately via assignableRoleIds;
// the ladder is the absolute hierarchy. Custom roles aren't in the
// table and are refused (TRI-8747's follow-up will handle them).
type LadderRole = { id: string };
function buildRoleLevel(roles: ReadonlyArray<LadderRole>): Record<string, number> {
const level: Record<string, number> = {};
roles.forEach((r, i) => {
// Top of the array = highest level. Subtract from length so larger
// numbers always mean "more authority" — no off-by-one when a role
// is added or removed.
level[r.id] = roles.length - i;
});
return level;
}
function isAtOrBelow(
roles: ReadonlyArray<LadderRole>,
inviterRoleId: string | null,
invitedRoleId: string
): boolean {
// No RBAC role on inviter (e.g. the runtime fallback couldn't derive
// one) → fall back to the legacy OrgMember.role check the calling
// code already enforces. Allow the invite to proceed; the action
// would have already failed earlier if the inviter wasn't allowed
// to invite at all.
if (!inviterRoleId) return true;
const level = buildRoleLevel(roles);
const inviter = level[inviterRoleId];
const invited = level[invitedRoleId];
// Custom roles aren't in the level table — refuse.
if (inviter === undefined || invited === undefined) return false;
return invited <= inviter;
}
const schema = z.object({
emails: z.preprocess((i) => {
if (typeof i === "string") return [i];
@@ -80,6 +150,7 @@ const schema = z.object({
return [""];
}, z.string().email().array().nonempty("At least one email is required")),
rbacRoleId: z.string().optional(),
});
export const action: ActionFunction = async ({ request, params }) => {
@@ -94,11 +165,62 @@ export const action: ActionFunction = async ({ request, params }) => {
return json(submission);
}
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
// role → don't pass one through; the runtime fallback handles it.
// Validation: the chosen role must be in the org's assignable set
// (plan-tier) and at or below the inviter's own level.
let resolvedRbacRoleId: string | null = null;
const submittedRbacRoleId = submission.value.rbacRoleId;
if (
submittedRbacRoleId &&
submittedRbacRoleId !== NO_RBAC_ROLE
) {
const org = await $replica.organization.findFirst({
where: { slug: organizationSlug },
select: { id: true },
});
if (!org) {
return json({ errors: { body: "Organization not found" } }, { status: 404 });
}
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
rbac.getUserRole({ userId, organizationId: org.id }),
rbac.getAssignableRoleIds(org.id),
rbac.systemRoles(org.id),
]);
if (!systemRoles) {
// No plugin installed but the form somehow submitted a role id —
// ignore it (fall through to legacy behaviour rather than 400).
resolvedRbacRoleId = null;
} else {
const assignable = new Set(assignableRoleIds);
if (!assignable.has(submittedRbacRoleId)) {
return json(
{ errors: { body: "You can't invite someone with this role on your current plan" } },
{ status: 400 }
);
}
if (
!isAtOrBelow(
systemRoles,
inviterRole?.id ?? null,
submittedRbacRoleId
)
) {
return json(
{ errors: { body: "You can only invite members at or below your own role" } },
{ status: 403 }
);
}
resolvedRbacRoleId = submittedRbacRoleId;
}
}
try {
const invites = await inviteMembers({
slug: organizationSlug,
emails: submission.value.emails,
userId,
rbacRoleId: resolvedRbacRoleId,
});
for (const invite of invites) {
@@ -128,12 +250,35 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const { limits, canPurchaseSeats, seatPricing, extraSeats, maxSeatQuota, planSeatLimit } =
useTypedLoaderData<typeof loader>();
const {
limits,
canPurchaseSeats,
seatPricing,
extraSeats,
maxSeatQuota,
planSeatLimit,
roles,
offerableRoleIds,
} = useTypedLoaderData<typeof loader>();
const [total, setTotal] = useState(limits.used);
const organization = useOrganization();
const lastSubmission = useActionData();
// The loader filtered the catalogue to roles this inviter can
// actually assign (plan tier × strict-below-my-level). With no plugin
// installed, offerableRoleIds is [] and the picker hides entirely.
const offerableSet = new Set(offerableRoleIds);
const offerable = roles.filter((r) => offerableSet.has(r.id));
const showRolePicker = offerable.length > 0;
// Default to the lowest-tier offered role (the loader returns roles
// in its allRoles order, which the plugin emits Owner→Member; the
// last entry is the most restrictive).
const defaultRoleId = showRolePicker
? offerable[offerable.length - 1].id
: NO_RBAC_ROLE;
const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
const [form, { emails }] = useForm({
id: "invite-members",
// TODO: type this
@@ -232,6 +377,36 @@ export default function Page() {
</Fragment>
))}
</InputGroup>
{showRolePicker ? (
<InputGroup>
<Label htmlFor="rbacRoleId">Role</Label>
<input type="hidden" name="rbacRoleId" value={selectedRoleId} />
<Select<string, (typeof offerable)[number]>
defaultValue={defaultRoleId}
items={offerable}
variant="tertiary/medium"
dropdownIcon
text={(v) =>
offerable.find((r) => r.id === v)?.name ?? "Pick a role"
}
setValue={(next) => {
if (typeof next === "string") setSelectedRoleId(next);
}}
>
{(items) =>
items.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))
}
</Select>
<Paragraph variant="extra-small" className="text-text-dimmed">
Invitees join with this role. They can be promoted later
from the Team page.
</Paragraph>
</InputGroup>
) : null}
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"} disabled={total > limits.limit}>
@@ -0,0 +1,396 @@
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { useState } from "react";
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button } from "~/components/primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { cn } from "~/utils/cn";
import { $replica } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { rbac } from "~/services/rbac.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { TextLink } from "~/components/primitives/TextLink";
export const meta: MetaFunction = () => {
return [
{
title: `Roles | Trigger.dev`,
},
];
};
const Params = z.object({
organizationSlug: z.string(),
});
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
const org = await $replica.organization.findFirst({
where: { slug },
select: { id: true },
});
return org?.id ?? null;
}
export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
authorization: { action: "read", resource: { type: "members" } },
},
async ({ context }) => {
const orgId = context.organizationId;
if (!orgId) {
throw new Response("Not Found", { status: 404 });
}
const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin] =
await Promise.all([
rbac.allRoles(orgId),
rbac.getAssignableRoleIds(orgId),
rbac.allPermissions(orgId),
rbac.systemRoles(orgId),
// OSS self-host: no enterprise plugin → no role infrastructure to
// show. Render a "roles aren't available" layout in that case
// rather than the plan-upsell empty state (which assumes a cloud
// plan and would be misleading).
rbac.isUsingPlugin(),
]);
return typedjson({
roles,
assignableRoleIds,
allPermissions,
systemRoles,
isUsingPlugin,
});
}
);
type LoaderData = UseDataFunctionReturn<typeof loader>;
type LoaderRole = LoaderData["roles"][number];
type LoaderPermission = LoaderData["allPermissions"][number];
type RolePermission = LoaderRole["permissions"][number];
// Permissions are bucketed by `permission.group` from the plugin.
// Section order = first-seen order in `allPermissions()`. Permissions
// without a group fall into "Other" at the bottom.
const FALLBACK_GROUP = "Other";
export default function Page() {
const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin } =
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const plan = useCurrentPlan();
const planCode = plan?.v3Subscription?.plan?.code;
const isEnterprise = planCode === "enterprise";
// Map role-id → role for fast cell lookup. Each role's permissions are
// already the expanded `effectivePermissions` output (system roles
// populated server-side; custom roles too) so cells just filter that
// list by permission name.
const rolesById = new Map<string, LoaderRole>(roles.map((r) => [r.id, r]));
const assignable = new Set(assignableRoleIds);
// Column ordering follows the plugin's canonical systemRoles order
// (highest authority first), then any custom roles in the order
// rbac.allRoles returned them. systemRoles is null when no plugin is
// installed; fall through to whatever order rbac.allRoles returns.
// Each entry's `available` flag reflects plan-tier eligibility — we
// render unavailable system roles too, but PlanBadge tags them so
// customers see the comparison and know what an upgrade unlocks.
const systemRoleOrder = systemRoles ?? [];
const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id));
const systemColumns = systemRoleOrder.flatMap((meta) => {
const role = rolesById.get(meta.id);
return role ? [{ role, fallbackName: meta.name }] : [];
});
const customColumns = roles
.filter((r) => !systemRoleIdSet.has(r.id))
.map((role) => ({ role, fallbackName: role.name }));
const columns = [...systemColumns, ...customColumns];
const grouped = groupPermissions(allPermissions);
return (
<PageContainer>
<NavBar>
<PageTitle title="Roles" />
{/* Suppress the Enterprise-upsell button on OSS — there's no
plan to upgrade to in a self-hosted deployment, and the
dialog copy ("Available on the Enterprise plan") doesn't
apply. The not-supported empty state below makes the
absence of role infrastructure clear instead. */}
{isUsingPlugin && !isEnterprise ? <CreateRoleUpsell /> : null}
</NavBar>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr]">
<div className="border-b border-grid-bright px-4 py-6">
<Paragraph variant="small">
Roles control what each team member can do in <strong>{organization.title}</strong>.
Compare what each role grants below; assign a role to a team member from the{" "}
<TextLink to={`/orgs/${organization.slug}/settings/team`}>Team page</TextLink>.
</Paragraph>
</div>
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
{columns.length === 0 ? (
<EmptyState isUsingPlugin={isUsingPlugin} />
) : (
<Table containerClassName="border-t-0">
<TableHeader>
<TableRow>
<TableHeaderCell>Permission</TableHeaderCell>
{columns.map(({ role }) => (
<TableHeaderCell key={role.id}>
<div className="flex items-center gap-1">
<span>{role.name}</span>
<PlanBadge
roleId={role.id}
assignable={assignable}
systemRoleIdSet={systemRoleIdSet}
/>
</div>
</TableHeaderCell>
))}
<TableHeaderCell>Description</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{grouped.length === 0 ? (
<TableBlankRow colSpan={columns.length + 2}>
<Paragraph variant="small" className="text-text-dimmed">
No permissions to display.
</Paragraph>
</TableBlankRow>
) : (
grouped.flatMap(({ group, permissions }) => [
<TableRow key={`${group}-header`}>
<TableCell colSpan={columns.length + 2} className="bg-charcoal-800">
<Header3 className="text-xs uppercase tracking-wide text-text-dimmed">
{group}
</Header3>
</TableCell>
</TableRow>,
...permissions.map((permission) => (
<TableRow key={permission.name}>
<TableCell>
<code className="text-xs">{permission.name}</code>
</TableCell>
{columns.map(({ role }) => (
<TableCell key={role.id}>
<RoleCell
permissionName={permission.name}
rolePermissions={role.permissions}
/>
</TableCell>
))}
<TableCell>
<Paragraph variant="small">
{permission.description || (
<span className="text-text-dimmed"></span>
)}
</Paragraph>
</TableCell>
</TableRow>
)),
])
)}
</TableBody>
</Table>
)}
</div>
</div>
</PageBody>
</PageContainer>
);
}
function EmptyState({ isUsingPlugin }: { isUsingPlugin: boolean }) {
// Two distinct empty states:
//
// 1. Plugin loaded, but rbac.allRoles returned nothing the org can
// use under its plan tier. The plan-upsell copy is correct —
// upgrade unlocks the role infrastructure.
// 2. No plugin loaded (OSS self-host). There's no "plan" to upgrade
// to. RBAC simply isn't part of this deployment; we use a
// permissive ability for every authenticated user and rely on
// org-membership for access control. Surface that honestly
// instead of dangling a fake upgrade carrot.
if (!isUsingPlugin) {
return (
<div className="flex flex-col items-center gap-2 p-8 text-center">
<Header3>Roles aren't available in this self-hosted deployment.</Header3>
<Paragraph variant="small" className="text-text-dimmed">
All members have full access. Role-Based Access Controls are available in Trigger.dev
Cloud or with an enterprise self-hosted license.
</Paragraph>
</div>
);
}
return (
<div className="flex flex-col items-center gap-2 p-8 text-center">
<Header3>No roles available on this plan.</Header3>
<Paragraph variant="small" className="text-text-dimmed">
Upgrade to Pro to unlock RBAC.
</Paragraph>
</div>
);
}
function PlanBadge({
roleId,
assignable,
systemRoleIdSet,
}: {
roleId: string;
assignable: ReadonlySet<string>;
systemRoleIdSet: ReadonlySet<string>;
}) {
// Roles the org's plan doesn't permit get a small upgrade-tier hint
// in the column header. The cell rendering is identical regardless
// — the comparison value is still useful even on Free/Hobby.
if (assignable.has(roleId)) return null;
// System roles render as "Pro" (the gating tier where they unlock —
// Free/Hobby see Owner+Admin only, Pro adds the rest). Custom roles
// render as "Enterprise" — only Enterprise plans can create or assign
// them.
if (systemRoleIdSet.has(roleId)) {
return <Badge variant="extra-small">Pro</Badge>;
}
return <Badge variant="extra-small">Enterprise</Badge>;
}
// Render a single (role × permission) cell. Filters the role's
// effectivePermissions list to entries matching this permission name
// and emits an icon + optional condition badge based on the rules.
function RoleCell({
permissionName,
rolePermissions,
}: {
permissionName: string;
rolePermissions: RolePermission[];
}) {
const matching = rolePermissions.filter((p) => p.name === permissionName);
if (matching.length === 0) {
// No rule matches — the role denies this permission by omission.
return (
<span className="text-text-dimmed" aria-label="Not granted">
<XMarkIcon className="size-4" />
</span>
);
}
const allowed = matching.filter((p) => !p.inverted);
const denied = matching.filter((p) => p.inverted);
// Only inverted rules apply — the role explicitly denies this
// permission. Render as ✗ in error colour.
if (allowed.length === 0) {
return (
<span className="text-error" aria-label="Denied">
<XMarkIcon className="size-4" />
</span>
);
}
// At least one allow rule applies. If there's a conditional cannot
// rule, replace the ✓ with just the condition label so the user sees
// the restriction without a misleading tick. Plain unconditional
// allow keeps the ✓.
const conditionalDeny = denied.find((p) => p.conditions);
if (conditionalDeny?.conditions) {
return (
<span className="text-xs text-text-dimmed">{conditionLabel(conditionalDeny.conditions)}</span>
);
}
return (
<span className="text-success" aria-label="Allowed">
<CheckIcon className="size-4" />
</span>
);
}
// Render a CASL conditions object into a tier badge label. Only
// `envType` is recognised today (the catalogue's only allowed condition);
// extending this requires adding a new branch when ALLOWED_CONDITIONS
// grows.
function conditionLabel(conditions: Record<string, unknown>): string {
if (typeof conditions.envType === "string") {
if (conditions.envType === "PRODUCTION") return "Non-prod only";
return `Non-${conditions.envType.toLowerCase()} only`;
}
return JSON.stringify(conditions);
}
function groupPermissions(
permissions: LoaderPermission[]
): { group: string; permissions: LoaderPermission[] }[] {
// Insertion-ordered map: groups appear in the order their first
// permission was seen. Plugins that want a specific section order
// just emit permissions in that order from `allPermissions()`.
const buckets = new Map<string, LoaderPermission[]>();
for (const permission of permissions) {
const group = permission.group ?? FALLBACK_GROUP;
const list = buckets.get(group) ?? [];
list.push(permission);
buckets.set(group, list);
}
return Array.from(buckets, ([group, permissions]) => ({ group, permissions }));
}
function CreateRoleUpsell() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary/small">Create role</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Custom roles are an Enterprise feature</DialogHeader>
<div className="flex flex-col gap-3 pt-2">
<Paragraph>
Define your own roles with bespoke permission sets perfect for "Member, but no
production deploys" or a vendor/contractor role. Available on the Enterprise plan.
</Paragraph>
<Paragraph variant="small" className="text-text-dimmed">
Get in touch and we'll walk you through the Enterprise plan and how custom roles fit
your team.
</Paragraph>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="secondary/medium" onClick={() => setOpen(false)}>
Maybe later
</Button>
<Button
variant="primary/medium"
onClick={() => {
window.open("https://trigger.dev/contact", "_blank");
setOpen(false);
}}
>
Contact us
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -9,7 +9,7 @@ import {
useFetcher,
useNavigation,
} from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { useEffect, useRef, useState } from "react";
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -41,24 +41,27 @@ import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Select, SelectItem, SelectLinkItem } from "~/components/primitives/Select";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { cn } from "~/utils/cn";
import { $replica } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useUser } from "~/hooks/useUser";
import { removeTeamMember } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { requireUserId } from "~/services/session.server";
import { rbac } from "~/services/rbac.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { cn } from "~/utils/cn";
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import {
inviteTeamMemberPath,
organizationRolesPath,
organizationTeamPath,
resendInvitePath,
revokeInvitePath,
v3BillingPath,
} from "~/utils/pathBuilder";
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import { SetSeatsAddOnService } from "~/v3/services/setSeatsAddOn.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
@@ -74,31 +77,51 @@ const Params = z.object({
organizationSlug: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = Params.parse(params);
const organization = await $replica.organization.findFirst({
where: { slug: organizationSlug },
// Resolve slug → orgId in the dashboardLoader's context callback so the
// rbac.authenticateSession call gets a real organizationId. The result
// is cached for the duration of the request and reused by the handler
// below (we re-find by slug there to get a typed value — the context
// only sees the loosely typed return type).
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
const org = await $replica.organization.findFirst({
where: { slug },
select: { id: true },
});
return org?.id ?? null;
}
if (!organization) {
throw new Response("Not Found", { status: 404 });
export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
authorization: { action: "read", resource: { type: "members" } },
},
async ({ user, ability, context }) => {
const orgId = context.organizationId;
if (!orgId) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new TeamPresenter();
const result = await presenter.call({
userId: user.id,
organizationId: orgId,
});
if (!result) {
throw new Response("Not Found", { status: 404 });
}
// Pre-compute manage authority server-side so the UI gating matches
// the action gating (the action enforces it independently).
const canManageMembers = ability.can("manage", { type: "members" });
return typedjson({ ...result, canManageMembers });
}
const presenter = new TeamPresenter();
const result = await presenter.call({
userId,
organizationId: organization.id,
});
if (!result) {
throw new Response("Not Found", { status: 404 });
}
return typedjson(result);
};
);
const schema = z.object({
memberId: z.string(),
@@ -111,89 +134,157 @@ const PurchaseSchema = z.discriminatedUnion("action", [
}),
z.object({
action: z.literal("quota-increase"),
amount: z.coerce
.number()
.int("Must be a whole number")
.min(1, "Amount must be greater than 0"),
amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"),
}),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = params;
invariant(organizationSlug, "organizationSlug not found");
const SetRoleSchema = z.object({
userId: z.string(),
roleId: z.string(),
});
const formData = await request.formData();
const formType = formData.get("_formType");
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
// No top-level authorization — different intents have different
// requirements. Each branch inside checks the right ability:
// set-role → manage:members
// purchase-seats → manage:billing
// remove-member → manage:members (skipped for self-leave)
// Don't rely on the model-layer (removeTeamMember /
// SetSeatsAddOnService) for enforcement — those are defense in
// depth; the route layer is where the ability gate belongs.
},
async ({ user, ability, request, params, context }) => {
const userId = user.id;
const { organizationSlug } = params;
invariant(organizationSlug, "organizationSlug not found");
if (formType === "purchase-seats") {
const org = await $replica.organization.findFirst({
where: { slug: organizationSlug },
select: { id: true },
});
const formData = await request.formData();
const formType = formData.get("_formType");
if (!org) {
return json({ ok: false, error: "Organization not found" } as const);
if (formType === "set-role") {
if (!ability.can("manage", { type: "members" })) {
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
}
const orgId = context.organizationId;
if (!orgId) {
return json({ ok: false, error: "Organization not found" } as const, { status: 404 });
}
const submission = parse(formData, { schema: SetRoleSchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const result = await rbac.setUserRole({
userId: submission.value.userId,
organizationId: orgId,
roleId: submission.value.roleId,
});
if (!result.ok) {
return json({ ok: false, error: result.error } as const, { status: 400 });
}
return json({ ok: true } as const);
}
const submission = parse(formData, { schema: PurchaseSchema });
if (formType === "purchase-seats") {
// Adjusting seat count is a billing operation. Pre-RBAC the team
// page's loader gated the entire route on Owner/Admin, so reaching
// this action implied authority. Post-RBAC the loader requires
// `read:members` (broader audience), so gate the seat purchase
// explicitly here against the right ability rather than relying
// on the SetSeatsAddOnService for enforcement at the model layer.
if (!ability.can("manage", { type: "billing" })) {
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
}
// Reuse the orgId the dashboardBuilder already resolved in the
// context callback (single slug → orgId lookup per request,
// regardless of whether the OSS fallback or cloud plugin
// services the auth — the plugin takes `organizationId` as
// input and doesn't re-resolve from a slug).
const orgId = context.organizationId;
if (!orgId) {
return json({ ok: false, error: "Organization not found" } as const);
}
const submission = parse(formData, { schema: PurchaseSchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const service = new SetSeatsAddOnService();
const [error, result] = await tryCatch(
service.call({
userId,
organizationId: orgId,
action: submission.value.action,
amount: submission.value.amount,
})
);
if (error) {
submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
return json(submission);
}
if (!result.success) {
submission.error.amount = [result.error];
return json(submission);
}
return json({ ok: true } as const);
}
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const service = new SetSeatsAddOnService();
const [error, result] = await tryCatch(
service.call({
userId,
organizationId: org.id,
action: submission.value.action,
amount: submission.value.amount,
})
);
if (error) {
submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
return json(submission);
}
if (!result.success) {
submission.error.amount = [result.error];
return json(submission);
}
return json({ ok: true } as const);
}
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const deletedMember = await removeTeamMember({
userId,
memberId: submission.value.memberId,
slug: organizationSlug,
// Default intent: remove a member or leave the org. Self-leave (the
// actor removing their own membership) is always allowed. Removing
// another member requires `manage:members` — pre-RBAC the
// `removeTeamMember` model fn only verified the actor was a member
// of the target org, so any org member could remove any other
// member by id; this gate fixes that latent permissions hole.
const targetMember = await $replica.orgMember.findFirst({
where: { id: submission.value.memberId },
select: { userId: true },
});
if (deletedMember.userId === userId) {
return redirectWithSuccessMessage("/", request, `You left the organization`);
const isSelfLeave = targetMember?.userId === userId;
if (!isSelfLeave && !ability.can("manage", { type: "members" })) {
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
}
return redirectWithSuccessMessage(
organizationTeamPath(deletedMember.organization),
request,
`Removed ${deletedMember.user.name ?? "member"} from team`
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
try {
const deletedMember = await removeTeamMember({
userId,
memberId: submission.value.memberId,
slug: organizationSlug,
});
if (deletedMember.userId === userId) {
return redirectWithSuccessMessage("/", request, `You left the organization`);
}
return redirectWithSuccessMessage(
organizationTeamPath(deletedMember.organization),
request,
`Removed ${deletedMember.user.name ?? "member"} from team`
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}
};
);
type Member = UseDataFunctionReturn<typeof loader>["members"][number];
type Invite = UseDataFunctionReturn<typeof loader>["invites"][number];
type Role = UseDataFunctionReturn<typeof loader>["roles"][number];
export default function Page() {
const {
@@ -205,7 +296,16 @@ export default function Page() {
seatPricing,
maxSeatQuota,
planSeatLimit,
roles,
assignableRoleIds,
memberRoles,
canManageMembers,
} = useTypedLoaderData<typeof loader>();
// Build a userId → roleId map so the dropdown's defaultValue matches
// each member's current assignment without re-querying.
const memberRoleByUserId = new Map<string, string>(
memberRoles.flatMap((m) => (m.role ? [[m.userId, m.role.id]] : []))
);
const user = useUser();
const organization = useOrganization();
@@ -242,10 +342,31 @@ export default function Page() {
))}
</Property.Table>
</AdminDebugTooltip>
{requiresUpgrade ? (
{!canManageMembers ? (
// Gate the invite affordance on manage:members. The action
// route enforces this independently — hiding it here just
// avoids dead UI for non-managers.
<SimpleTooltip
button={
<ButtonContent variant="primary/small" LeadingIcon={UserPlusIcon} className="cursor-not-allowed opacity-50">
<ButtonContent
variant="primary/small"
LeadingIcon={UserPlusIcon}
className="cursor-not-allowed opacity-50"
>
Invite a team member
</ButtonContent>
}
content="You don't have permission to invite team members"
disableHoverableContent
/>
) : requiresUpgrade ? (
<SimpleTooltip
button={
<ButtonContent
variant="primary/small"
LeadingIcon={UserPlusIcon}
className="cursor-not-allowed opacity-50"
>
Invite a team member
</ButtonContent>
}
@@ -291,34 +412,57 @@ export default function Page() {
</ul>
</>
)}
<Header2>Active team members</Header2>
<ul className="divide-ui-border mb-8 mt-3 flex w-full flex-col divide-y border-y border-grid-bright">
<div className="mt-4 flex items-baseline justify-between">
<Header2>Active team members</Header2>
{roles.length > 0 ? (
<a
className="text-xs text-text-link hover:underline"
href={organizationRolesPath(organization)}
>
View all role permissions
</a>
) : null}
</div>
<div className="mb-8 mt-3 grid w-full grid-cols-[1fr_auto_auto] items-center gap-x-2 border-y border-grid-bright">
{members.map((member) => (
<li key={member.user.id} className="flex items-center gap-x-4 py-4">
<UserAvatar
avatarUrl={member.user.avatarUrl}
name={member.user.name}
className="size-10"
/>
<div className="flex flex-col gap-0.5">
<Header3>
{member.user.name}{" "}
{member.user.id === user.id && (
<span className="text-text-dimmed">(You)</span>
)}
</Header3>
<Paragraph variant="small">{member.user.email}</Paragraph>
<div
key={member.user.id}
className="col-span-3 grid grid-cols-subgrid items-center gap-x-2 border-b border-grid-bright py-2 last:border-b-0"
>
<div className="flex items-center gap-x-2">
<UserAvatar
avatarUrl={member.user.avatarUrl}
name={member.user.name}
className="size-10"
/>
<div className="flex flex-col gap-0.5">
<Header3>
{member.user.name}{" "}
{member.user.id === user.id && (
<span className="text-text-dimmed">(You)</span>
)}
</Header3>
<Paragraph variant="small">{member.user.email}</Paragraph>
</div>
</div>
<div className="flex grow items-center justify-end gap-4">
<RolePicker
memberUserId={member.user.id}
currentRoleId={memberRoleByUserId.get(member.user.id) ?? null}
roles={roles}
assignableRoleIds={assignableRoleIds}
canManageMembers={canManageMembers}
/>
<div className="justify-self-end">
<LeaveRemoveButton
userId={user.id}
member={member}
memberCount={members.length}
canManageMembers={canManageMembers}
/>
</div>
</li>
</div>
))}
</ul>
</div>
</div>
</div>
@@ -387,10 +531,12 @@ function LeaveRemoveButton({
userId,
member,
memberCount,
canManageMembers,
}: {
userId: string;
member: Member;
memberCount: number;
canManageMembers: boolean;
}) {
const organization = useOrganization();
@@ -409,7 +555,8 @@ function LeaveRemoveButton({
);
}
//you leave the team
//you leave the team — leaving is always permitted regardless of
//manage:members; non-managers can still leave on their own.
return (
<LeaveTeamModal
member={member}
@@ -421,7 +568,20 @@ function LeaveRemoveButton({
);
}
//you remove another member
//you remove another member — requires manage:members
if (!canManageMembers) {
return (
<SimpleTooltip
button={
<ButtonContent variant="secondary/small" className="cursor-not-allowed opacity-50">
Remove from team
</ButtonContent>
}
disableHoverableContent
content="You don't have permission to remove team members"
/>
);
}
return (
<LeaveTeamModal
member={member}
@@ -433,6 +593,100 @@ function LeaveRemoveButton({
);
}
// Inline role picker — submits a `_formType=set-role` form via fetcher
// so the change persists without a full page reload. Disabled options
// (and the picker itself) reflect plan gating + manage:members; the
// server's setUserRole enforces both checks again as the source of
// truth, so this is a UI-affordance layer only.
function RolePicker({
memberUserId,
currentRoleId,
roles,
assignableRoleIds,
canManageMembers,
}: {
memberUserId: string;
currentRoleId: string | null;
roles: Role[];
assignableRoleIds: string[];
canManageMembers: boolean;
}) {
const organization = useOrganization();
const fetcher = useFetcher<{ ok: boolean; error?: string } | { ok: true }>();
const assignable = new Set(assignableRoleIds);
// With no RBAC plugin installed, the loader returns no roles —
// render nothing rather than an empty dropdown.
if (roles.length === 0) return null;
const isSubmitting = fetcher.state === "submitting";
const error =
fetcher.data && "error" in fetcher.data && fetcher.data.error ? fetcher.data.error : null;
const picker = (
<Select
// Controlled — keeps the dropdown in sync with the persisted
// role even after a failed set-role fetcher submit (the server
// kept the old role; without `value` the UI would show the
// attempted change).
value={currentRoleId ?? ""}
items={roles}
variant="tertiary/small"
disabled={!canManageMembers || isSubmitting}
dropdownIcon
text={(v) => roles.find((r) => r.id === v)?.name ?? "No role"}
setValue={(next) => {
if (typeof next !== "string" || next === (currentRoleId ?? "")) return;
// Upgrade-link rows have a value too (Ariakit needs one to
// make the row interactive — without it the Link inside
// doesn't even register the click), but they shouldn't
// submit the role-change form. The Link navigates the user
// to the plan-selection page; we just bail here.
if (!assignable.has(next)) return;
fetcher.submit(
{ _formType: "set-role", userId: memberUserId, roleId: next },
{ method: "post" }
);
}}
>
{(items) =>
items.map((role) => {
const isAssignable = assignable.has(role.id);
return isAssignable ? (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
) : (
<SelectLinkItem key={role.id} value={role.id} to={v3BillingPath(organization)}>
{role.name} (upgrade)
</SelectLinkItem>
);
})
}
</Select>
);
return (
<div className="flex flex-col items-end gap-1">
{canManageMembers ? (
picker
) : (
// Disabled <Select> swallows hover events on its own, so wrap it
// in a div the tooltip can attach to.
<SimpleTooltip
button={<div className="cursor-not-allowed">{picker}</div>}
content="You don't have permission to change roles"
disableHoverableContent
/>
)}
{error ? (
<span className="text-xs text-error" role="alert">
{error}
</span>
) : null}
</div>
);
}
function LeaveTeamModal({
member,
buttonText,
@@ -8,6 +8,7 @@ import {
OrganizationSettingsSideMenu,
} from "~/components/navigation/OrganizationSettingsSideMenu";
import { useOrganization } from "~/hooks/useOrganizations";
import { rbac } from "~/services/rbac.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
@@ -18,17 +19,22 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
gitRefName: process.env.BUILD_GIT_REF_NAME,
buildTimestampSeconds: process.env.BUILD_TIMESTAMP_SECONDS,
} satisfies BuildInfo,
isUsingPlugin: await rbac.isUsingPlugin(),
});
};
export default function Page() {
const { buildInfo } = useTypedLoaderData<typeof loader>();
const { buildInfo, isUsingPlugin } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
return (
<AppContainer>
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
<OrganizationSettingsSideMenu organization={organization} buildInfo={buildInfo} />
<OrganizationSettingsSideMenu
organization={organization}
buildInfo={buildInfo}
isUsingPlugin={isUsingPlugin}
/>
<MainBody>
<Outlet />
</MainBody>
+146 -4
View File
@@ -5,6 +5,7 @@ import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, type MetaFunction, useActionData, useFetcher } from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
@@ -22,6 +23,7 @@ import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import {
Table,
TableBlankRow,
@@ -34,6 +36,8 @@ import {
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { prisma } from "~/db.server";
import { rbac } from "~/services/rbac.server";
import {
type CreatedPersonalAccessToken,
type ObfuscatedPersonalAccessToken,
@@ -52,14 +56,82 @@ export const meta: MetaFunction = () => {
];
};
// PATs aren't org-scoped, but the RBAC plugin's allRoles is org-keyed
// (a plugin may also expose org-defined custom roles alongside the
// global system roles). The picker shows the assignable system role
// catalogue for the user's primary org — joining `allRoles` (for the
// full Role with permissions) against `systemRoles` (for the per-org
// `available` flag, which gates roles by plan tier). This is a UI-only
// convenience — the chosen role becomes a global TokenRole that
// applies wherever the PAT is used. Custom (org-defined) roles are
// out of scope for v1: their org-binding semantics for a multi-org
// user's PAT need a separate design pass.
async function loadSystemRolesForUser(userId: string) {
const orgMember = await prisma.orgMember.findFirst({
where: { userId },
select: { organizationId: true },
orderBy: { createdAt: "asc" },
});
if (!orgMember) {
return {
roles: [],
userRoleId: null as string | null,
orgId: null as string | null,
};
}
const [allRoles, systemRoles, userRole] = await Promise.all([
rbac.allRoles(orgMember.organizationId),
rbac.systemRoles(orgMember.organizationId),
rbac.getUserRole({ userId, organizationId: orgMember.organizationId }),
]);
// Restrict the picker to system roles the plan permits assigning —
// anything else would be a noisy create-time failure (or, with a
// permissive fallback, a token bound to a role this org isn't
// allowed to issue).
const availableIds = new Set(
(systemRoles ?? []).filter((r) => r.available).map((r) => r.id)
);
const roles = allRoles.filter((r) => r.isSystem && availableIds.has(r.id));
return {
roles,
userRoleId: userRole?.id ?? null,
orgId: orgMember.organizationId,
};
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
try {
const personalAccessTokens = await getValidPersonalAccessTokens(userId);
const [personalAccessTokens, { roles, userRoleId, orgId }] = await Promise.all([
getValidPersonalAccessTokens(userId),
loadSystemRolesForUser(userId),
]);
// Default the role picker to the user's own role in their primary
// org so a freshly-created PAT isn't more privileged than the
// person creating it. Falls back to the most-restrictive role
// available on the org's plan if they don't have one. When the
// user isn't a member of any org or no RBAC plugin is installed,
// the picker is hidden anyway, so defaultRoleId is just a
// placeholder.
// Clamp to roles the picker actually renders (`roles` already
// joins systemRoles ∩ assignableRoleIds). If userRoleId points at
// a custom or plan-blocked role, the hidden form value would
// otherwise post a roleId the action's revalidation rejects with
// 400. Fall through to the most-restrictive assignable role.
const assignableIds = new Set(roles.map((r) => r.id));
const lowestAssignable = roles.at(-1)?.id ?? "";
const defaultRoleId =
userRoleId && assignableIds.has(userRoleId) ? userRoleId : lowestAssignable;
return typedjson({
personalAccessTokens,
roles,
defaultRoleId,
});
} catch (error) {
if (error instanceof Response) {
@@ -81,6 +153,10 @@ const CreateTokenSchema = z.discriminatedUnion("action", [
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
// Optional — when no RBAC plugin is installed the UI hides the
// dropdown and submits no roleId; the action passes that through
// and createPersonalAccessToken just doesn't write a TokenRole.
roleId: z.string().optional(),
}),
z.object({
action: z.literal("revoke"),
@@ -100,9 +176,27 @@ export const action: ActionFunction = async ({ request }) => {
switch (submission.value.action) {
case "create": {
try {
// Revalidate the submitted roleId against the plan-allowed set
// — the loader filters the picker, but a hand-crafted POST can
// still submit any string. Empty / undefined is fine: that
// means "no role" and createPersonalAccessToken just doesn't
// write a TokenRole.
const submittedRoleId = submission.value.roleId;
if (submittedRoleId) {
const { roles } = await loadSystemRolesForUser(userId);
const allowed = new Set(roles.map((r) => r.id));
if (!allowed.has(submittedRoleId)) {
return json(
{ errors: { body: "Selected role isn't available on this plan" } },
{ status: 400 }
);
}
}
const tokenResult = await createPersonalAccessToken({
name: submission.value.tokenName,
userId,
roleId: submittedRoleId,
});
return json({ ...submission, payload: { token: tokenResult } });
@@ -131,7 +225,7 @@ export const action: ActionFunction = async ({ request }) => {
};
export default function Page() {
const { personalAccessTokens } = useTypedLoaderData<typeof loader>();
const { personalAccessTokens, roles, defaultRoleId } = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
@@ -151,7 +245,7 @@ export default function Page() {
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Create a Personal Access Token</DialogHeader>
<CreatePersonalAccessToken />
<CreatePersonalAccessToken roles={roles} defaultRoleId={defaultRoleId} />
</DialogContent>
</Dialog>
</PageAccessories>
@@ -211,7 +305,15 @@ export default function Page() {
);
}
function CreatePersonalAccessToken() {
type SystemRole = { id: string; name: string; description: string };
function CreatePersonalAccessToken({
roles,
defaultRoleId,
}: {
roles: SystemRole[];
defaultRoleId: string;
}) {
const fetcher = useFetcher<typeof action>();
const lastSubmission = fetcher.data as any;
@@ -228,6 +330,14 @@ function CreatePersonalAccessToken() {
? (lastSubmission?.payload?.token as CreatedPersonalAccessToken)
: undefined;
// With no RBAC plugin installed, rbac.allRoles returns []; hide the
// dropdown entirely rather than showing an empty Select.
// createPersonalAccessToken's roleId is optional, so omitting it
// produces a working PAT with no explicit role attached (matches
// pre-RBAC behaviour).
const showRolePicker = roles.length > 0;
const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
return (
<div className="max-w-full overflow-x-hidden">
{token ? (
@@ -248,6 +358,7 @@ function CreatePersonalAccessToken() {
) : (
<fetcher.Form method="post" {...form.props}>
<input type="hidden" name="action" value="create" />
{showRolePicker && <input type="hidden" name="roleId" value={selectedRoleId} />}
<Fieldset className="mt-3">
<InputGroup>
<Label htmlFor={tokenName.id}>Name</Label>
@@ -265,6 +376,37 @@ function CreatePersonalAccessToken() {
<FormError id={tokenName.errorId}>{tokenName.error}</FormError>
</InputGroup>
{showRolePicker && (
<InputGroup>
<Label>Maximum role</Label>
<Select<string, SystemRole>
value={selectedRoleId}
setValue={(v) => setSelectedRoleId(v)}
items={roles}
variant="tertiary/small"
dropdownIcon
text={(v) => roles.find((r) => r.id === v)?.name ?? "Select a role"}
>
{(items) =>
items.map((role) => (
<SelectItem key={role.id} value={role.id}>
<span className="flex flex-col">
<span>{role.name}</span>
{role.description ? (
<span className="text-xs text-text-dimmed">{role.description}</span>
) : null}
</span>
</SelectItem>
))
}
</Select>
<Hint>
The token can act with up to this role. Your current role in each org is the
actual ceiling the token never grants more than you have.
</Hint>
</InputGroup>
)}
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
+27 -11
View File
@@ -1,6 +1,5 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -17,8 +16,8 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { adminGetUsers } from "~/models/admin.server";
import { requireUserId } from "~/services/session.server";
import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { createSearchParams } from "~/utils/searchParams";
export const SearchParams = z.object({
@@ -28,17 +27,34 @@ export const SearchParams = z.object({
export type SearchParams = z.infer<typeof SearchParams>;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async ({ user, request }) => {
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) {
throw new Error(searchParams.error);
}
const result = await adminGetUsers(user.id, searchParams.params.getAll());
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) {
throw new Error(searchParams.error);
return typedjson(result);
}
const result = await adminGetUsers(userId, searchParams.params.getAll());
);
return typedjson(result);
};
const FormSchema = z.object({ id: z.string() });
export const action = dashboardAction(
{ authorization: { requireSuper: true } },
async ({ request }) => {
if (request.method.toLowerCase() !== "post") {
return new Response("Method not allowed", { status: 405 });
}
const payload = Object.fromEntries(await request.formData());
const { id } = FormSchema.parse(payload);
return redirectWithImpersonation(request, id, "/");
}
);
export default function AdminDashboardRoute() {
const { users, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
@@ -1,17 +1,15 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson } from "remix-typedjson";
import { typedjson } from "remix-typedjson";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { requireUser } from "~/services/session.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) {
return redirect("/");
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async () => {
return typedjson({});
}
return typedjson({});
}
);
export default function BackOfficeIndex() {
return (
@@ -1,6 +1,6 @@
import { useNavigation, useSearchParams } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useEffect } from "react";
import { z } from "zod";
import {
redirect,
typedjson,
@@ -36,59 +36,50 @@ import { CopyableText } from "~/components/primitives/CopyableText";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
const SAVED_QUERY_KEY = "saved";
export async function loader({ request, params }: LoaderFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) {
return redirect("/");
const ParamsSchema = z.object({
orgId: z.string(),
});
export const loader = dashboardLoader(
{ authorization: { requireSuper: true }, params: ParamsSchema },
async ({ params }) => {
const orgId = params.orgId;
const org = await prisma.organization.findFirst({
where: { id: orgId },
select: {
id: true,
slug: true,
title: true,
createdAt: true,
apiRateLimiterConfig: true,
batchRateLimitConfig: true,
maximumProjectCount: true,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
const apiEffective = resolveEffectiveApiRateLimit(org.apiRateLimiterConfig);
const batchEffective = resolveEffectiveBatchRateLimit(org.batchRateLimitConfig);
return typedjson({ org, apiEffective, batchEffective });
}
);
const orgId = params.orgId;
if (!orgId) {
throw new Response(null, { status: 404 });
}
export const action = dashboardAction(
{ authorization: { requireSuper: true }, params: ParamsSchema },
async ({ user, params, request }) => {
const orgId = params.orgId;
const org = await prisma.organization.findFirst({
where: { id: orgId },
select: {
id: true,
slug: true,
title: true,
createdAt: true,
apiRateLimiterConfig: true,
batchRateLimitConfig: true,
maximumProjectCount: true,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
const apiEffective = resolveEffectiveApiRateLimit(org.apiRateLimiterConfig);
const batchEffective = resolveEffectiveBatchRateLimit(
org.batchRateLimitConfig
);
return typedjson({ org, apiEffective, batchEffective });
}
export async function action({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) {
return redirect("/");
}
const orgId = params.orgId;
if (!orgId) {
throw new Response(null, { status: 404 });
}
const formData = await request.formData();
const intent = formData.get("intent");
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === MAX_PROJECTS_INTENT) {
const result = await handleMaxProjectsAction(formData, orgId, user.id);
@@ -129,11 +120,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
);
}
return typedjson(
{ section: null, errors: { intent: ["Unknown intent."] } },
{ status: 400 }
);
}
return typedjson(
{ section: null, errors: { intent: ["Unknown intent."] } },
{ status: 400 }
);
}
);
export default function BackOfficeOrgPage() {
const { org, apiEffective, batchEffective } =
+7 -9
View File
@@ -1,15 +1,13 @@
import { Outlet } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson } from "remix-typedjson";
import { requireUser } from "~/services/session.server";
import { typedjson } from "remix-typedjson";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) {
return redirect("/");
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async () => {
return typedjson({});
}
return typedjson({});
}
);
export default function BackOfficeLayout() {
return (
+9 -13
View File
@@ -1,23 +1,19 @@
import { InformationCircleIcon } from "@heroicons/react/20/solid";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Header1 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Paragraph } from "~/components/primitives/Paragraph";
import { requireUser } from "~/services/session.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
if (!user.admin) {
return redirect("/");
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async () => {
const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true);
const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false);
return typedjson({ deployedConcurrency, devConcurrency });
}
const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true);
const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false);
return typedjson({ deployedConcurrency, devConcurrency });
};
);
export default function AdminDashboardRoute() {
const { deployedConcurrency, devConcurrency } = useTypedLoaderData<typeof loader>();
+47 -49
View File
@@ -1,14 +1,16 @@
import { useFetcher } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useEffect, useState } from "react";
import stableStringify from "json-stable-stringify";
import { json } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { LockClosedIcon } from "@heroicons/react/20/solid";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { requireUser } from "~/services/session.server";
import {
dashboardAction,
dashboardLoader,
} from "~/services/routeBuilders/dashboardBuilder";
import {
FEATURE_FLAG,
GLOBAL_LOCKED_FLAGS,
@@ -38,53 +40,48 @@ import {
type WorkerGroup,
} from "~/components/admin/FlagControls";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
if (!user.admin) {
return redirect("/");
}
const [globalFlags, workerGroups] = await Promise.all([
getGlobalFlags(),
prisma.workerInstanceGroup.findMany({
select: { id: true, name: true },
orderBy: { name: "asc" },
}),
]);
const controlTypes = getAllFlagControlTypes();
// Resolve env-based defaults for locked flags
const resolvedDefaults: Record<string, string> = {
[FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE,
};
// Look up worker group name if the flag is set
const workerGroupId = (globalFlags as Record<string, unknown>)?.[
FEATURE_FLAG.defaultWorkerInstanceGroupId
];
const workerGroupName =
typeof workerGroupId === "string"
? workerGroups.find((wg) => wg.id === workerGroupId)?.name
: undefined;
const { isManagedCloud } = featuresForRequest(request);
return typedjson({
globalFlags,
controlTypes,
resolvedDefaults,
workerGroupName,
workerGroups,
isManagedCloud,
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
if (!user.admin) {
throw new Response("Unauthorized", { status: 403 });
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async ({ request }) => {
const [globalFlags, workerGroups] = await Promise.all([
getGlobalFlags(),
prisma.workerInstanceGroup.findMany({
select: { id: true, name: true },
orderBy: { name: "asc" },
}),
]);
const controlTypes = getAllFlagControlTypes();
// Resolve env-based defaults for locked flags
const resolvedDefaults: Record<string, string> = {
[FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE,
};
// Look up worker group name if the flag is set
const workerGroupId = (globalFlags as Record<string, unknown>)?.[
FEATURE_FLAG.defaultWorkerInstanceGroupId
];
const workerGroupName =
typeof workerGroupId === "string"
? workerGroups.find((wg) => wg.id === workerGroupId)?.name
: undefined;
const { isManagedCloud } = featuresForRequest(request);
return typedjson({
globalFlags,
controlTypes,
resolvedDefaults,
workerGroupName,
workerGroups,
isManagedCloud,
});
}
);
export const action = dashboardAction(
{ authorization: { requireSuper: true } },
async ({ request }) => {
let body: unknown;
try {
body = await request.json();
@@ -156,7 +153,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
]);
return json({ success: true });
};
}
);
export default function AdminFeatureFlagsRoute() {
const {
@@ -1,5 +1,4 @@
import { Form, useActionData, useNavigate } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -8,34 +7,37 @@ import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Input } from "~/components/primitives/Input";
import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
const ParamsSchema = z.object({
modelId: z.string(),
});
const model = await prisma.llmModel.findUnique({
where: { friendlyId: params.modelId },
include: {
pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } },
},
});
export const loader = dashboardLoader(
{ authorization: { requireSuper: true }, params: ParamsSchema },
async ({ params }) => {
const model = await prisma.llmModel.findUnique({
where: { friendlyId: params.modelId },
include: {
pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } },
},
});
if (!model) throw new Response("Model not found", { status: 404 });
if (!model) throw new Response("Model not found", { status: 404 });
// Convert Prisma Decimal to plain numbers for serialization
const serialized = {
...model,
pricingTiers: model.pricingTiers.map((t) => ({
...t,
prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })),
})),
};
// Convert Prisma Decimal to plain numbers for serialization
const serialized = {
...model,
pricingTiers: model.pricingTiers.map((t) => ({
...t,
prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })),
})),
};
return typedjson({ model: serialized });
};
return typedjson({ model: serialized });
}
);
const SaveSchema = z.object({
modelName: z.string().min(1),
@@ -49,100 +51,99 @@ const SaveSchema = z.object({
isHidden: z.string().optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
export const action = dashboardAction(
{ authorization: { requireSuper: true }, params: ParamsSchema },
async ({ params, request }) => {
const friendlyId = params.modelId;
const existing = await prisma.llmModel.findUnique({ where: { friendlyId } });
if (!existing) throw new Response("Model not found", { status: 404 });
const modelId = existing.id;
const friendlyId = params.modelId!;
const existing = await prisma.llmModel.findUnique({ where: { friendlyId } });
if (!existing) throw new Response("Model not found", { status: 404 });
const modelId = existing.id;
const formData = await request.formData();
const _action = formData.get("_action");
const formData = await request.formData();
const _action = formData.get("_action");
if (_action === "delete") {
await prisma.llmModel.delete({ where: { id: modelId } });
await llmPricingRegistry?.reload();
return redirect("/admin/llm-models");
}
if (_action === "save") {
const raw = Object.fromEntries(formData);
const parsed = SaveSchema.safeParse(raw);
if (!parsed.success) {
return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 });
if (_action === "delete") {
await prisma.llmModel.delete({ where: { id: modelId } });
await llmPricingRegistry?.reload();
return redirect("/admin/llm-models");
}
const { modelName, matchPattern, pricingTiersJson } = parsed.data;
if (_action === "save") {
const raw = Object.fromEntries(formData);
const parsed = SaveSchema.safeParse(raw);
// Validate regex — strip (?i) POSIX flag since our registry handles it
try {
const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern;
new RegExp(testPattern);
} catch {
return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 });
}
if (!parsed.success) {
return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 });
}
// Parse tiers
let pricingTiers: Array<{
name: string;
isDefault: boolean;
priority: number;
conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
prices: Record<string, number>;
}>;
try {
pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers;
} catch {
return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
}
const { modelName, matchPattern, pricingTiersJson } = parsed.data;
// Update model
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
await prisma.llmModel.update({
where: { id: modelId },
data: {
modelName,
matchPattern,
provider: provider || null,
description: description || null,
contextWindow: contextWindow ? parseInt(contextWindow) || null : null,
maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
isHidden: isHidden === "on",
},
});
// Validate regex — strip (?i) POSIX flag since our registry handles it
try {
const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern;
new RegExp(testPattern);
} catch {
return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 });
}
// Replace tiers
await prisma.llmPricingTier.deleteMany({ where: { modelId } });
for (const tier of pricingTiers) {
await prisma.llmPricingTier.create({
// Parse tiers
let pricingTiers: Array<{
name: string;
isDefault: boolean;
priority: number;
conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
prices: Record<string, number>;
}>;
try {
pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers;
} catch {
return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
}
// Update model
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
await prisma.llmModel.update({
where: { id: modelId },
data: {
modelId,
name: tier.name,
isDefault: tier.isDefault,
priority: tier.priority,
conditions: tier.conditions,
prices: {
create: Object.entries(tier.prices).map(([usageType, price]) => ({
modelId,
usageType,
price,
})),
},
modelName,
matchPattern,
provider: provider || null,
description: description || null,
contextWindow: contextWindow ? parseInt(contextWindow) || null : null,
maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
isHidden: isHidden === "on",
},
});
// Replace tiers
await prisma.llmPricingTier.deleteMany({ where: { modelId } });
for (const tier of pricingTiers) {
await prisma.llmPricingTier.create({
data: {
modelId,
name: tier.name,
isDefault: tier.isDefault,
priority: tier.priority,
conditions: tier.conditions,
prices: {
create: Object.entries(tier.prices).map(([usageType, price]) => ({
modelId,
usageType,
price,
})),
},
},
});
}
await llmPricingRegistry?.reload();
return typedjson({ success: true });
}
await llmPricingRegistry?.reload();
return typedjson({ success: true });
return typedjson({ error: "Unknown action" }, { status: 400 });
}
return typedjson({ error: "Unknown action" }, { status: 400 });
}
);
export default function AdminLlmModelDetailRoute() {
const { model } = useTypedLoaderData<typeof loader>();
+103 -107
View File
@@ -1,7 +1,5 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form, useFetcher, Link } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -18,7 +16,7 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { seedLlmPricing, syncLlmCatalog } from "@internal/llm-model-catalog";
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
@@ -30,121 +28,119 @@ const SearchParams = z.object({
search: z.string().optional(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async ({ request }) => {
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) throw new Error(searchParams.error);
const { page: rawPage, search } = searchParams.params.getAll();
const page = rawPage ?? 1;
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) throw new Error(searchParams.error);
const { page: rawPage, search } = searchParams.params.getAll();
const page = rawPage ?? 1;
const where = {
projectId: null as string | null,
...(search ? { modelName: { contains: search, mode: "insensitive" as const } } : {}),
};
const where = {
projectId: null as string | null,
...(search ? { modelName: { contains: search, mode: "insensitive" as const } } : {}),
};
const [rawModels, total] = await Promise.all([
prisma.llmModel.findMany({
where,
include: {
pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } },
},
orderBy: { modelName: "asc" },
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
prisma.llmModel.count({ where }),
]);
const [rawModels, total] = await Promise.all([
prisma.llmModel.findMany({
where,
include: {
pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } },
},
orderBy: { modelName: "asc" },
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
}),
prisma.llmModel.count({ where }),
]);
// Convert Prisma Decimal to plain numbers for serialization
const models = rawModels.map((m) => ({
...m,
pricingTiers: m.pricingTiers.map((t) => ({
...t,
prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })),
})),
}));
return typedjson({
models,
total,
page,
pageCount: Math.ceil(total / PAGE_SIZE),
filters: { search },
});
};
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
const formData = await request.formData();
const _action = formData.get("_action");
if (_action === "seed") {
console.log("[admin] seed action started");
const result = await seedLlmPricing(prisma);
console.log(`[admin] seed complete: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`);
await llmPricingRegistry?.reload();
console.log("[admin] registry reloaded after seed");
return typedjson({
success: true,
message: `Seeded: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`,
});
}
if (_action === "sync") {
console.log("[admin] sync catalog action started");
const result = await syncLlmCatalog(prisma);
console.log(`[admin] sync complete: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`);
await llmPricingRegistry?.reload();
console.log("[admin] registry reloaded after sync");
return typedjson({
success: true,
message: `Synced: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`,
});
}
if (_action === "reload") {
console.log("[admin] reload action started");
await llmPricingRegistry?.reload();
console.log("[admin] registry reloaded");
return typedjson({ success: true, message: "Registry reloaded" });
}
if (_action === "test") {
const modelString = formData.get("modelString");
if (typeof modelString !== "string" || !modelString) {
return typedjson({ testResult: null });
}
// Use the registry's match() which handles prefix stripping automatically
const matched = llmPricingRegistry?.match(modelString) ?? null;
// Convert Prisma Decimal to plain numbers for serialization
const models = rawModels.map((m) => ({
...m,
pricingTiers: m.pricingTiers.map((t) => ({
...t,
prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })),
})),
}));
return typedjson({
testResult: {
modelString,
match: matched
? { friendlyId: matched.friendlyId, modelName: matched.modelName }
: null,
},
models,
total,
page,
pageCount: Math.ceil(total / PAGE_SIZE),
filters: { search },
});
}
);
if (_action === "delete") {
const modelId = formData.get("modelId");
if (typeof modelId === "string") {
await prisma.llmModel.delete({ where: { id: modelId } });
export const action = dashboardAction(
{ authorization: { requireSuper: true } },
async ({ request }) => {
const formData = await request.formData();
const _action = formData.get("_action");
if (_action === "seed") {
console.log("[admin] seed action started");
const result = await seedLlmPricing(prisma);
console.log(`[admin] seed complete: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`);
await llmPricingRegistry?.reload();
console.log("[admin] registry reloaded after seed");
return typedjson({
success: true,
message: `Seeded: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`,
});
}
return typedjson({ success: true });
}
return typedjson({ error: "Unknown action" }, { status: 400 });
}
if (_action === "sync") {
console.log("[admin] sync catalog action started");
const result = await syncLlmCatalog(prisma);
console.log(`[admin] sync complete: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`);
await llmPricingRegistry?.reload();
console.log("[admin] registry reloaded after sync");
return typedjson({
success: true,
message: `Synced: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`,
});
}
if (_action === "reload") {
console.log("[admin] reload action started");
await llmPricingRegistry?.reload();
console.log("[admin] registry reloaded");
return typedjson({ success: true, message: "Registry reloaded" });
}
if (_action === "test") {
const modelString = formData.get("modelString");
if (typeof modelString !== "string" || !modelString) {
return typedjson({ testResult: null });
}
// Use the registry's match() which handles prefix stripping automatically
const matched = llmPricingRegistry?.match(modelString) ?? null;
return typedjson({
testResult: {
modelString,
match: matched
? { friendlyId: matched.friendlyId, modelName: matched.modelName }
: null,
},
});
}
if (_action === "delete") {
const modelId = formData.get("modelId");
if (typeof modelId === "string") {
await prisma.llmModel.delete({ where: { id: modelId } });
await llmPricingRegistry?.reload();
}
return typedjson({ success: true });
}
return typedjson({ error: "Unknown action" }, { status: 400 });
}
);
export default function AdminLlmModelsRoute() {
const { models, filters, page, pageCount, total } =
@@ -1,39 +1,40 @@
import { useState } from "react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import {
getMissingModelSamples,
type MissingModelSample,
} from "~/services/admin/missingLlmModels.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
const ParamsSchema = z.object({
model: z.string(),
});
// Model name is URL-encoded in the URL param
const modelName = decodeURIComponent(params.model ?? "");
if (!modelName) throw new Response("Missing model param", { status: 400 });
export const loader = dashboardLoader(
{ authorization: { requireSuper: true }, params: ParamsSchema },
async ({ params, request }) => {
// Model name is URL-encoded in the URL param
const modelName = decodeURIComponent(params.model);
if (!modelName) throw new Response("Missing model param", { status: 400 });
const url = new URL(request.url);
const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10);
const url = new URL(request.url);
const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10);
let samples: MissingModelSample[] = [];
let error: string | undefined;
let samples: MissingModelSample[] = [];
let error: string | undefined;
try {
samples = await getMissingModelSamples({ model: modelName, lookbackHours, limit: 10 });
} catch (e) {
error = e instanceof Error ? e.message : "Failed to query ClickHouse";
try {
samples = await getMissingModelSamples({ model: modelName, lookbackHours, limit: 10 });
} catch (e) {
error = e instanceof Error ? e.message : "Failed to query ClickHouse";
}
return typedjson({ modelName, samples, lookbackHours, error });
}
return typedjson({ modelName, samples, lookbackHours, error });
};
);
export default function AdminMissingModelDetailRoute() {
const { modelName, samples, lookbackHours, error } = useTypedLoaderData<typeof loader>();
@@ -1,6 +1,4 @@
import { useSearchParams } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { LinkButton } from "~/components/primitives/Buttons";
@@ -14,8 +12,7 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { getMissingLlmModels } from "~/services/admin/missingLlmModels.server";
const LOOKBACK_OPTIONS = [
@@ -30,25 +27,24 @@ const SearchParams = z.object({
lookbackHours: z.coerce.number().optional(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async ({ request }) => {
const url = new URL(request.url);
const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10);
const url = new URL(request.url);
const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10);
let models: Awaited<ReturnType<typeof getMissingLlmModels>> = [];
let error: string | undefined;
let models: Awaited<ReturnType<typeof getMissingLlmModels>> = [];
let error: string | undefined;
try {
models = await getMissingLlmModels({ lookbackHours });
} catch (e) {
error = e instanceof Error ? e.message : "Failed to query ClickHouse";
}
try {
models = await getMissingLlmModels({ lookbackHours });
} catch (e) {
error = e instanceof Error ? e.message : "Failed to query ClickHouse";
return typedjson({ models, lookbackHours, error });
}
return typedjson({ models, lookbackHours, error });
};
);
export default function AdminLlmModelsMissingRoute() {
const { models, lookbackHours, error } = useTypedLoaderData<typeof loader>();
+73 -75
View File
@@ -1,5 +1,4 @@
import { Form, useActionData, useSearchParams } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
@@ -7,16 +6,16 @@ import { useState } from "react";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Input } from "~/components/primitives/Input";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
return typedjson({});
};
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async () => {
return typedjson({});
}
);
const CreateSchema = z.object({
modelName: z.string().min(1),
@@ -30,83 +29,82 @@ const CreateSchema = z.object({
isHidden: z.string().optional(),
});
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
export const action = dashboardAction(
{ authorization: { requireSuper: true } },
async ({ request }) => {
const formData = await request.formData();
const raw = Object.fromEntries(formData);
console.log("[admin] create model form data:", JSON.stringify(raw).slice(0, 500));
const parsed = CreateSchema.safeParse(raw);
const formData = await request.formData();
const raw = Object.fromEntries(formData);
console.log("[admin] create model form data:", JSON.stringify(raw).slice(0, 500));
const parsed = CreateSchema.safeParse(raw);
if (!parsed.success) {
console.log("[admin] create model validation error:", JSON.stringify(parsed.error.issues));
return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 });
}
if (!parsed.success) {
console.log("[admin] create model validation error:", JSON.stringify(parsed.error.issues));
return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 });
}
const { modelName, matchPattern, pricingTiersJson } = parsed.data;
const { modelName, matchPattern, pricingTiersJson } = parsed.data;
// Validate regex — strip (?i) POSIX flag since our registry handles it
try {
const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern;
new RegExp(testPattern);
} catch {
return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 });
}
// Validate regex — strip (?i) POSIX flag since our registry handles it
try {
const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern;
new RegExp(testPattern);
} catch {
return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 });
}
let pricingTiers: Array<{
name: string;
isDefault: boolean;
priority: number;
conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
prices: Record<string, number>;
}>;
try {
pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers;
} catch {
return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
}
let pricingTiers: Array<{
name: string;
isDefault: boolean;
priority: number;
conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
prices: Record<string, number>;
}>;
try {
pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers;
} catch {
return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
}
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
const model = await prisma.llmModel.create({
data: {
friendlyId: generateFriendlyId("llm_model"),
modelName,
matchPattern,
source: "admin",
provider: provider || null,
description: description || null,
contextWindow: contextWindow ? parseInt(contextWindow) || null : null,
maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
isHidden: isHidden === "on",
},
});
for (const tier of pricingTiers) {
await prisma.llmPricingTier.create({
const model = await prisma.llmModel.create({
data: {
modelId: model.id,
name: tier.name,
isDefault: tier.isDefault,
priority: tier.priority,
conditions: tier.conditions,
prices: {
create: Object.entries(tier.prices).map(([usageType, price]) => ({
modelId: model.id,
usageType,
price,
})),
},
friendlyId: generateFriendlyId("llm_model"),
modelName,
matchPattern,
source: "admin",
provider: provider || null,
description: description || null,
contextWindow: contextWindow ? parseInt(contextWindow) || null : null,
maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
isHidden: isHidden === "on",
},
});
}
await llmPricingRegistry?.reload();
return redirect(`/admin/llm-models/${model.friendlyId}`);
}
for (const tier of pricingTiers) {
await prisma.llmPricingTier.create({
data: {
modelId: model.id,
name: tier.name,
isDefault: tier.isDefault,
priority: tier.priority,
conditions: tier.conditions,
prices: {
create: Object.entries(tier.prices).map(([usageType, price]) => ({
modelId: model.id,
usageType,
price,
})),
},
},
});
}
await llmPricingRegistry?.reload();
return redirect(`/admin/llm-models/${model.friendlyId}`);
}
);
export default function AdminLlmModelNewRoute() {
const actionData = useActionData<{ error?: string; details?: unknown[] }>();
+44 -48
View File
@@ -1,7 +1,5 @@
import { TrashIcon } from "@heroicons/react/20/solid";
import { useFetcher, useSearchParams } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -41,9 +39,8 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { prisma } from "~/db.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import {
archivePlatformNotification,
createPlatformNotification,
@@ -68,55 +65,54 @@ const SearchParams = z.object({
hideInactive: z.coerce.boolean().optional(),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async ({ user, request }) => {
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) throw new Error(searchParams.error);
const { page: rawPage, hideInactive } = searchParams.params.getAll();
const page = rawPage ?? 1;
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) throw new Error(searchParams.error);
const { page: rawPage, hideInactive } = searchParams.params.getAll();
const page = rawPage ?? 1;
const data = await getAdminNotificationsList({
page,
pageSize: PAGE_SIZE,
hideInactive: hideInactive ?? false,
});
const data = await getAdminNotificationsList({
page,
pageSize: PAGE_SIZE,
hideInactive: hideInactive ?? false,
});
return typedjson({ ...data, userId });
};
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.admin) throw redirect("/");
const formData = await request.formData();
const _action = formData.get("_action");
if (_action === "create" || _action === "create-preview") {
return handleCreateAction(formData, userId, _action === "create-preview");
return typedjson({ ...data, userId: user.id });
}
);
if (_action === "archive") {
return handleArchiveAction(formData);
export const action = dashboardAction(
{ authorization: { requireSuper: true } },
async ({ user, request }) => {
const userId = user.id;
const formData = await request.formData();
const _action = formData.get("_action");
if (_action === "create" || _action === "create-preview") {
return handleCreateAction(formData, userId, _action === "create-preview");
}
if (_action === "archive") {
return handleArchiveAction(formData);
}
if (_action === "delete") {
return handleDeleteAction(formData);
}
if (_action === "publish-now") {
return handlePublishNowAction(formData);
}
if (_action === "edit") {
return handleEditAction(formData);
}
return typedjson({ error: "Unknown action" }, { status: 400 });
}
if (_action === "delete") {
return handleDeleteAction(formData);
}
if (_action === "publish-now") {
return handlePublishNowAction(formData);
}
if (_action === "edit") {
return handleEditAction(formData);
}
return typedjson({ error: "Unknown action" }, { status: 400 });
}
);
function parseNotificationFormData(formData: FormData) {
const surface = formData.get("surface") as string;
+12 -15
View File
@@ -1,7 +1,6 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useState } from "react";
import { z } from "zod";
import { FeatureFlagsDialog } from "~/components/admin/FeatureFlagsDialog";
@@ -20,7 +19,7 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { adminGetOrganizations } from "~/models/admin.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { createSearchParams } from "~/utils/searchParams";
export const SearchParams = z.object({
@@ -30,20 +29,18 @@ export const SearchParams = z.object({
export type SearchParams = z.infer<typeof SearchParams>;
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
if (!user.admin) {
return redirect("/");
}
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async ({ user, request }) => {
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) {
throw new Error(searchParams.error);
}
const result = await adminGetOrganizations(user.id, searchParams.params.getAll());
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) {
throw new Error(searchParams.error);
return typedjson(result);
}
const result = await adminGetOrganizations(user.id, searchParams.params.getAll());
return typedjson(result);
};
);
export default function AdminDashboardRoute() {
const { organizations, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
+6 -11
View File
@@ -1,18 +1,13 @@
import { Outlet } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson } from "remix-typedjson";
import { typedjson } from "remix-typedjson";
import { LinkButton } from "~/components/primitives/Buttons";
import { Tabs } from "~/components/primitives/Tabs";
import { requireUser } from "~/services/session.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) {
return redirect("/");
}
return typedjson({ user });
}
export const loader = dashboardLoader(
{ authorization: { requireSuper: true } },
async ({ user }) => typedjson({ user })
);
export default function Page() {
return (
@@ -1,7 +1,7 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
batchId: z.string(),
@@ -25,8 +25,19 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
resource: (batch) => ({ batch: batch.friendlyId }),
superScopes: ["read:runs", "read:all", "admin"],
// Pre-RBAC, this route's `superScopes` included `read:runs`, so a
// JWT minted with `read:runs` could read batches. The new strict
// scope-type match means `read:runs` no longer trivially matches
// `{type: "batch"}`. Include `{type: "runs"}` (alongside the
// batch-id-scoped element) to preserve that semantic for any
// SDK-issued tokens in the wild — a `read:runs` JWT still passes
// batch retrieval. Per-id `read:batch:<id>` and type-level
// `read:batch` still grant via the first element.
resource: (batch) =>
anyResource([
{ type: "batch", id: batch.friendlyId },
{ type: "runs" },
]),
},
},
async ({ resource: batch }) => {
+1 -2
View File
@@ -69,8 +69,7 @@ export const loader = createLoaderApiRoute(
corsStrategy: "none",
authorization: {
action: "read",
resource: () => ({ deployments: "list" }),
superScopes: ["read:deployments", "read:all", "admin"],
resource: () => ({ type: "deployments", id: "list" }),
},
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
},
@@ -21,8 +21,7 @@ export const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "write",
resource: () => ({}),
superScopes: ["write:runs", "admin"],
resource: () => ({ type: "runs" }),
},
},
async ({ params, body, authentication }) => {
@@ -1,5 +1,6 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { findProjectByRef } from "~/models/project.server";
import {
ApiRunListPresenter,
@@ -16,6 +17,20 @@ export const loader = createLoaderPATApiRoute(
params: ParamsSchema,
searchParams: ApiRunListSearchParams,
corsStrategy: "all",
// Resolve projectRef → org so the PAT plugin can ground its
// role-floor calculation. We deliberately don't filter by user
// membership here — that's the plugin's job (`authenticatePat`
// checks OrgMember in the target org and rejects if the user
// isn't a member). Keeps the contract clean: context is "what
// org does this URL target?" and auth is "is this user allowed?"
context: async (params) => {
const project = await $replica.project.findFirst({
where: { externalRef: params.projectRef },
select: { organizationId: true },
});
return project ? { organizationId: project.organizationId } : {};
},
authorization: { action: "read", resource: () => ({ type: "runs" }) },
},
async ({ searchParams, params, authentication, apiVersion }) => {
const project = await findProjectByRef(params.projectRef, authentication.userId);
@@ -22,8 +22,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "update",
resource: (params) => ({ prompts: params.slug }),
superScopes: ["admin"],
resource: (params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ body, params, authentication }) => {
@@ -40,8 +40,7 @@ const { action, loader } = createMultiMethodApiRoute({
corsStrategy: "all",
authorization: {
action: "update",
resource: (params) => ({ prompts: params.slug }),
superScopes: ["admin"],
resource: (params) => ({ type: "prompts", id: params.slug }),
},
methods: {
POST: {
@@ -22,8 +22,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "update",
resource: (params) => ({ prompts: params.slug }),
superScopes: ["admin"],
resource: (params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ body, params, authentication }) => {
@@ -37,8 +37,7 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
resource: (_resource, params) => ({ prompts: params.slug }),
superScopes: ["read:prompts", "admin"],
resource: (_resource, params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ searchParams, resource: prompt }) => {
@@ -98,8 +97,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "read",
resource: (params) => ({ prompts: params.slug }),
superScopes: ["read:prompts", "admin"],
resource: (params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ body, params, authentication }) => {
@@ -27,8 +27,7 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
resource: (_resource, params) => ({ prompts: params.slug }),
superScopes: ["read:prompts", "admin"],
resource: (_resource, params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ resource: prompt }) => {
@@ -10,8 +10,7 @@ export const loader = createLoaderApiRoute(
findResource: async () => 1,
authorization: {
action: "read",
resource: () => ({ prompts: "all" }),
superScopes: ["read:prompts", "admin"],
resource: () => ({ type: "prompts", id: "all" }),
},
},
async ({ authentication }) => {
@@ -37,8 +37,7 @@ export const loader = createLoaderApiRoute(
findResource: async () => 1,
authorization: {
action: "read",
resource: () => ({ query: "dashboards" }),
superScopes: ["read:query", "read:all", "admin"],
resource: () => ({ type: "query", id: "dashboards" }),
},
},
async () => {
@@ -47,8 +47,7 @@ export const loader = createLoaderApiRoute(
findResource: async () => 1,
authorization: {
action: "read",
resource: () => ({ query: "schema" }),
superScopes: ["read:query", "read:all", "admin"],
resource: () => ({ type: "query", id: "schema" }),
},
},
async () => {
+11 -3
View File
@@ -1,7 +1,10 @@
import { json } from "@remix-run/server-runtime";
import { QueryError } from "@internal/clickhouse";
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
createActionApiRoute,
everyResource,
} from "~/services/routeBuilders/apiBuilder.server";
import { executeQuery, type QueryScope } from "~/services/queryService.server";
import { logger } from "~/services/logger.server";
import { rowsToCSV } from "~/utils/dataExport";
@@ -34,11 +37,16 @@ const { action, loader } = createActionApiRoute(
findResource: async () => 1,
authorization: {
action: "read",
// A multi-table query reads from every detected table. Wrap with
// everyResource so a JWT scoped to one table can't pass auth for
// a query that also reads tables it isn't scoped to (would be the
// same OR-loophole the batch trigger route had pre-fix).
resource: (_, __, ___, body) => {
const tables = detectTables(body.query);
return { query: tables.length > 0 ? tables : "all" };
return tables.length > 0
? everyResource(tables.map((id) => ({ type: "query", id })))
: { type: "query", id: "all" };
},
superScopes: ["read:query", "read:all", "admin"],
},
},
async ({ body, authentication }) => {
@@ -1,7 +1,10 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
@@ -21,13 +24,17 @@ export const loader = createLoaderApiRoute(
shouldRetryNotFound: true,
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batch?.friendlyId,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
resource: (run) => {
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 anyResource(resources);
},
},
},
async ({ resource: run, authentication }) => {
@@ -3,7 +3,10 @@ import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { z } from "zod";
import { $replica } from "~/db.server";
import { extractAISpanData } from "~/components/runs/v3/ai";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
@@ -28,13 +31,17 @@ export const loader = createLoaderApiRoute(
shouldRetryNotFound: true,
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
resource: (run) => {
const resources = [
{ type: "runs", id: run.friendlyId },
{ type: "tasks", id: run.taskIdentifier },
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
];
if (run.batchId) {
resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
}
return anyResource(resources);
},
},
},
async ({ params, resource: run, authentication }) => {
@@ -2,7 +2,10 @@ import { json } from "@remix-run/server-runtime";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { z } from "zod";
import { $replica } from "~/db.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
@@ -26,13 +29,17 @@ export const loader = createLoaderApiRoute(
shouldRetryNotFound: true,
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
resource: (run) => {
const resources = [
{ type: "runs", id: run.friendlyId },
{ type: "tasks", id: run.taskIdentifier },
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
];
if (run.batchId) {
resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
}
return anyResource(resources);
},
},
},
async ({ resource: run, authentication }) => {
+22 -3
View File
@@ -4,7 +4,10 @@ import {
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { logger } from "~/services/logger.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
export const loader = createLoaderApiRoute(
{
@@ -13,8 +16,24 @@ export const loader = createLoaderApiRoute(
corsStrategy: "all",
authorization: {
action: "read",
resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
superScopes: ["read:runs", "read:all", "admin"],
resource: (_, __, searchParams) => {
const taskFilter = searchParams["filter[taskIdentifier]"] ?? [];
// Pre-RBAC, the resource was `{ tasks: searchParams["filter[taskIdentifier]"] }`
// and the legacy `checkAuthorization` iterated `Object.keys` — so a
// JWT with type-level `read:tasks` (no id) granted access to the
// unfiltered runs list. The new ability model only matches against
// resources we list, so the type-level `{ type: "tasks" }` element
// (alongside `{ type: "runs" }` and the per-id task elements)
// preserves that semantic — `read:tasks` JWTs in the wild still
// list unfiltered runs without needing a separate `read:runs`
// scope. Per-id `read:tasks:foo` still grants only when the
// filter includes `foo`.
return anyResource([
{ type: "runs" },
{ type: "tasks" },
...taskFilter.map((id) => ({ type: "tasks", id })),
]);
},
},
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
},
@@ -25,8 +25,7 @@ const { action, loader } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "admin",
resource: (params) => ({ sessions: params.session }),
superScopes: ["admin:sessions", "admin:all", "admin"],
resource: (params) => ({ type: "sessions", id: params.session }),
},
},
async ({ authentication, params, body }) => {
@@ -8,7 +8,10 @@ import { $replica, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { swapSessionRun } from "~/services/realtime/sessionRunManager.server";
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createActionApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
session: z.string(),
@@ -42,15 +45,18 @@ const { action, loader } = createActionApiRoute(
resolveSessionByIdOrExternalId($replica, auth.environment.id, params.session),
authorization: {
action: "write",
// Multi-key: the session is addressable by URL param, friendlyId,
// and externalId — a JWT scoped to any of them grants access.
// Type-level `write:sessions` (no id) also matches; `write:all` /
// `admin` bypass via the JWT ability's wildcard branches.
resource: (params, _, __, ___, session) => {
const ids = new Set<string>([params.session]);
if (session) {
ids.add(session.friendlyId);
if (session.externalId) ids.add(session.externalId);
}
return { sessions: [...ids] };
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
},
superScopes: ["write:sessions", "write:all", "admin"],
},
},
async ({ authentication, params, body, resource: session }) => {
@@ -11,6 +11,7 @@ import {
serializeSessionWithFriendlyRunId,
} from "~/services/realtime/sessions.server";
import {
anyResource,
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
@@ -29,8 +30,17 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
resource: (session) => ({ sessions: [session.friendlyId, session.externalId ?? ""] }),
superScopes: ["read:sessions", "read:all", "admin"],
// Multi-key: a session is addressable by both friendlyId and (when
// set) externalId. A JWT scoped to either id grants access; type-
// level `read:sessions` (no id) matches both elements; `read:all`
// / `admin` bypass via the JWT ability's wildcard branches.
resource: (session) =>
session.externalId
? anyResource([
{ type: "sessions", id: session.friendlyId },
{ type: "sessions", id: session.externalId },
])
: { type: "sessions", id: session.friendlyId },
},
},
async ({ resource: session }) => {
@@ -50,8 +60,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "admin",
resource: (params) => ({ sessions: params.session }),
superScopes: ["admin:sessions", "admin:all", "admin"],
resource: (params) => ({ type: "sessions", id: params.session }),
},
},
async ({ authentication, params, body }) => {
+28 -15
View File
@@ -20,6 +20,7 @@ import {
import { serializeSession } from "~/services/realtime/sessions.server";
import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server";
import {
anyResource,
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
@@ -37,8 +38,21 @@ export const loader = createLoaderApiRoute(
corsStrategy: "all",
authorization: {
action: "read",
resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
superScopes: ["read:sessions", "read:all", "admin"],
// Multi-key resource preserves the pre-RBAC superScope semantics:
// - Per-task scoping via `read:tasks:<id>` matches a task element
// - Type-level `read:sessions` (the old superScope) matches the
// sessions element (collection-level — no id)
// - `read:all` / `admin` bypass via the JWT ability's wildcard branches
// The taskIdentifier filter accepts a string or an array; expand to
// one resource per task id so any per-task-scoped JWT among them
// grants access (the array gets OR semantics).
resource: (_, __, searchParams) => {
const taskFilter = asArray(searchParams["filter[taskIdentifier]"]) ?? [];
return anyResource([
...taskFilter.map((id) => ({ type: "tasks" as const, id })),
{ type: "sessions" as const },
]);
},
},
findResource: async () => 1,
},
@@ -113,21 +127,20 @@ const { action } = createActionApiRoute(
// Per-task scoping via `body.taskIdentifier` (action-route resource
// callbacks receive the parsed body as the 4th arg — see
// `apiBuilder.server.ts:710`). A JWT scoped only to `write:tasks:foo`
// can only create sessions whose `taskIdentifier` is `"foo"`. Broad
// callers (cli-v3 MCP, customer servers wrapping their own auth)
// hold the `write:sessions` super-scope and bypass the per-task
// check entirely.
// can only create sessions whose `taskIdentifier` is `"foo"`.
//
// Note: the auth check is OR across resource types, so listing both
// `sessions` and `tasks` here would let a `write:sessions`-scoped
// JWT pass for *any* task — defeating the per-task narrowing. Keep
// it task-only and let the super-scope path handle session-level
// wildcard access.
// Multi-key resource: pre-RBAC this route had a `superScopes:
// ["write:sessions", "admin"]` whitelist; post-RBAC the equivalent
// is the `{ type: "sessions" }` element below — a `write:sessions`
// JWT (no id) matches it directly, deliberately bypassing the
// per-task check exactly as before. `admin` / `write:all` bypass
// via the JWT ability's wildcard branches.
action: "write",
resource: (_params, _searchParams, _headers, body) => ({
tasks: body.taskIdentifier,
}),
superScopes: ["write:sessions", "admin"],
resource: (_params, _searchParams, _headers, body) =>
anyResource([
{ type: "tasks", id: body.taskIdentifier },
{ type: "sessions" },
]),
},
corsStrategy: "all",
},
@@ -51,8 +51,7 @@ const { action, loader } = createActionApiRoute(
maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "trigger",
resource: (params) => ({ tasks: params.taskId }),
superScopes: ["write:tasks", "admin"],
resource: (params) => ({ type: "tasks", id: params.taskId }),
},
corsStrategy: "all",
},
+15 -5
View File
@@ -7,7 +7,10 @@ import {
import { env } from "~/env.server";
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
createActionApiRoute,
everyResource,
} from "~/services/routeBuilders/apiBuilder.server";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import {
@@ -30,10 +33,17 @@ const { action, loader } = createActionApiRoute(
maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "batchTrigger",
resource: (_, __, ___, body) => ({
tasks: Array.from(new Set(body.items.map((i) => i.task))),
}),
superScopes: ["write:tasks", "admin"],
// Each item in the batch is a distinct task — every one must be
// authorized, not just any one of them. `everyResource` flips
// the auth check to AND semantics so a JWT scoped to taskA can't
// submit a batch that also includes taskB / taskC.
resource: (_, __, ___, body) =>
everyResource(
Array.from(new Set(body.items.map((i) => i.task))).map((id) => ({
type: "tasks",
id,
}))
),
},
corsStrategy: "all",
},
@@ -45,6 +45,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
orgMember: true,
parentEnvironment: {
select: {
id: true,
apiKey: true,
},
},
@@ -23,8 +23,7 @@ const { action, loader } = createActionApiRoute(
allowJWT: true,
authorization: {
action: "write",
resource: (params) => ({ waitpoints: params.waitpointFriendlyId }),
superScopes: ["write:waitpoints", "admin"],
resource: (params) => ({ type: "waitpoints", id: params.waitpointFriendlyId }),
},
corsStrategy: "all",
},
@@ -1,7 +1,7 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
batchId: z.string(),
@@ -25,8 +25,13 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
resource: (batch) => ({ batch: batch.friendlyId }),
superScopes: ["read:runs", "read:all", "admin"],
// See sibling note in api.v1.batches.$batchId.ts — `{type: "runs"}`
// preserves pre-RBAC `read:runs` superScope access for batch reads.
resource: (batch) =>
anyResource([
{ type: "batch", id: batch.friendlyId },
{ type: "runs" },
]),
},
},
async ({ resource: batch }) => {
@@ -15,8 +15,7 @@ const { action } = createActionApiRoute(
corsStrategy: "none",
authorization: {
action: "write",
resource: (params) => ({ runs: params.runParam }),
superScopes: ["write:runs", "admin"],
resource: (params) => ({ type: "runs", id: params.runParam }),
},
findResource: async (params, auth) => {
return $replica.taskRun.findFirst({
+15 -5
View File
@@ -9,7 +9,10 @@ import { env } from "~/env.server";
import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.server";
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
createActionApiRoute,
everyResource,
} from "~/services/routeBuilders/apiBuilder.server";
import {
handleRequestIdempotency,
saveRequestIdempotency,
@@ -32,10 +35,17 @@ const { action, loader } = createActionApiRoute(
maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "batchTrigger",
resource: (_, __, ___, body) => ({
tasks: Array.from(new Set(body.items.map((i) => i.task))),
}),
superScopes: ["write:tasks", "admin"],
// Each item in the batch is a distinct task — every one must be
// authorized, not just any one of them. `everyResource` flips
// the auth check to AND semantics so a JWT scoped to taskA can't
// submit a batch that also includes taskB / taskC.
resource: (_, __, ___, body) =>
everyResource(
Array.from(new Set(body.items.map((i) => i.task))).map((id) => ({
type: "tasks",
id,
}))
),
},
corsStrategy: "all",
},
+3 -6
View File
@@ -35,12 +35,9 @@ const { action, loader } = createActionApiRoute(
maxContentLength: 131_072, // 128KB is plenty for the batch metadata
authorization: {
action: "batchTrigger",
resource: () => ({
// No specific tasks to authorize at batch creation time
// Tasks are validated when items are streamed
tasks: [],
}),
superScopes: ["write:tasks", "admin"],
// No specific tasks to authorize at batch creation time — tasks are
// validated when items are streamed. Collection-level check.
resource: () => ({ type: "tasks" }),
},
corsStrategy: "all",
},
+15 -8
View File
@@ -1,7 +1,10 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -18,13 +21,17 @@ export const loader = createLoaderApiRoute(
shouldRetryNotFound: true,
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batch?.friendlyId,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
resource: (run) => {
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 anyResource(resources);
},
},
},
async ({ authentication, resource, apiVersion }) => {
@@ -5,7 +5,7 @@ import {
WorkerApiRunAttemptStartRequestBody,
WorkerApiRunAttemptStartResponseBody,
} from "@trigger.dev/core/v3/workers";
import { RuntimeEnvironment } from "@trigger.dev/database";
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
import { defaultMachine } from "~/services/platform.v3.server";
import { z } from "zod";
import { prisma } from "~/db.server";
@@ -76,7 +76,7 @@ const { action } = createActionApiRoute(
);
async function getEnvVars(
environment: RuntimeEnvironment,
environment: AuthenticatedEnvironment,
runId: string,
machinePreset: MachinePreset,
taskEventStore?: string
+1 -1
View File
@@ -4,7 +4,7 @@ import { env } from "process";
import { z } from "zod";
import { resendInvite } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { scheduleEmail } from "~/services/email.server";
import { scheduleEmail } from "~/services/scheduleEmail.server";
import { requireUserId } from "~/services/session.server";
import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder";
@@ -2,7 +2,7 @@ import { z } from "zod";
import { $replica } from "~/db.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
batchId: z.string(),
@@ -23,8 +23,13 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
resource: (batch) => ({ batch: batch.friendlyId }),
superScopes: ["read:runs", "read:all", "admin"],
// See sibling note in api.v1.batches.$batchId.ts — `{type: "runs"}`
// preserves pre-RBAC `read:runs` superScope access for batch reads.
resource: (batch) =>
anyResource([
{ type: "batch", id: batch.friendlyId },
{ type: "runs" },
]),
},
},
async ({ authentication, request, resource: batchRun, apiVersion }) => {
@@ -3,7 +3,10 @@ import { z } from "zod";
import { $replica } from "~/db.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -31,13 +34,17 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batch?.friendlyId,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
resource: (run) => {
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 anyResource(resources);
},
},
},
async ({ authentication, request, resource: run, apiVersion }) => {
+16 -3
View File
@@ -1,7 +1,10 @@
import { z } from "zod";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
const SearchParamsSchema = z.object({
tags: z
@@ -21,8 +24,18 @@ export const loader = createLoaderApiRoute(
findResource: async () => 1, // This is a dummy value, it's not used
authorization: {
action: "read",
resource: (_, __, searchParams) => searchParams,
superScopes: ["read:runs", "read:all", "admin"],
resource: (_, __, searchParams) =>
// Pre-RBAC, the resource was the searchParams object itself and
// the legacy `checkAuthorization` iterated `Object.keys`, so a
// JWT with type-level `read:tags` (no id) granted access to the
// unfiltered runs stream. Including `{ type: "tags" }` here
// preserves that — per-id `read:tags:<tag>` still grants only
// when the filter includes that tag.
anyResource([
{ type: "runs" },
{ type: "tags" },
...(searchParams.tags ?? []).map((tag) => ({ type: "tags", id: tag })),
]),
},
},
async ({ searchParams, authentication, request, apiVersion }) => {
@@ -12,7 +12,10 @@ import {
} from "~/services/realtime/sessions.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createActionApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { engine } from "~/v3/runEngine.server";
import { ServiceValidationError } from "~/v3/services/common.server";
@@ -49,15 +52,16 @@ const { action, loader } = createActionApiRoute(
action: "write",
// Authorize against the union of the URL form, friendlyId, and
// externalId so a JWT scoped to any form authorizes any URL.
// Type-level `write:sessions` (no id) also matches; `write:all` /
// `admin` bypass via the JWT ability's wildcard branches.
resource: (params, _, __, ___, session) => {
const ids = new Set<string>([params.session]);
if (session) {
ids.add(session.friendlyId);
if (session.externalId) ids.add(session.externalId);
}
return { sessions: [...ids] };
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
},
superScopes: ["write:sessions", "write:all", "admin"],
},
},
async ({ request, params, authentication, resource: session }) => {
@@ -10,6 +10,7 @@ import {
} from "~/services/realtime/sessions.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import {
anyResource,
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
@@ -30,8 +31,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "write",
resource: (params) => ({ sessions: params.session }),
superScopes: ["write:sessions", "write:all", "admin"],
resource: (params) => ({ type: "sessions", id: params.session }),
},
},
async ({ params, authentication }) => {
@@ -116,15 +116,18 @@ const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
// Multi-key: the channel is addressable by the URL key, the row's
// friendlyId, and (if set) externalId. Type-level `read:sessions`
// matches any of them; `read:all` / `admin` bypass via the JWT
// ability's wildcard branches.
resource: ({ row, addressingKey }) => {
const ids = new Set<string>([addressingKey]);
if (row) {
ids.add(row.friendlyId);
if (row.externalId) ids.add(row.externalId);
}
return { sessions: [...ids] };
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
},
superScopes: ["read:sessions", "read:all", "admin"],
},
},
async ({ params, request, authentication, resource }) => {
@@ -3,7 +3,10 @@ import { z } from "zod";
import { $replica } from "~/db.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import {
anyResource,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
const ParamsSchema = z.object({
@@ -89,7 +92,13 @@ export const loader = createLoaderApiRoute(
friendlyId: params.runId,
runtimeEnvironmentId: auth.environment.id,
},
include: {
select: {
id: true,
friendlyId: true,
taskIdentifier: true,
runTags: true,
realtimeStreamsVersion: true,
streamBasinName: true,
batch: {
select: {
friendlyId: true,
@@ -100,13 +109,17 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batch?.friendlyId,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
resource: (run) => {
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 anyResource(resources);
},
},
},
async ({ params, request, resource: run, authentication }) => {
@@ -7,6 +7,7 @@ import {
deleteInputStreamWaitpoint,
} from "~/services/inputStreamWaitpointCache.server";
import {
anyResource,
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
@@ -31,8 +32,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "write",
resource: (params) => ({ inputStreams: params.runId }),
superScopes: ["write:inputStreams", "write:all", "admin"],
resource: (params) => ({ type: "inputStreams", id: params.runId }),
},
},
async ({ request, params, authentication }) => {
@@ -127,13 +127,17 @@ const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batch?.friendlyId,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
resource: (run) => {
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 anyResource(resources);
},
},
},
async ({ params, request, resource: run, authentication }) => {
@@ -32,7 +32,16 @@ function createBatchLimitsRedisClient() {
return redisClient;
}
function createOrganizationRateLimiter(organization: Organization): RateLimiter {
// Just the org fields this module reads. Compatible with both the full
// Prisma `Organization` payload and the slim `AuthenticatedEnvironment`
// `["organization"]` shape (when passed `batchRateLimitConfig` /
// `batchQueueConcurrencyConfig` as `unknown`).
type OrganizationForBatchLimits = {
batchRateLimitConfig?: unknown;
batchQueueConcurrencyConfig?: unknown;
};
function createOrganizationRateLimiter(organization: OrganizationForBatchLimits): RateLimiter {
const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig);
const limiter = createLimiterFromConfig(limiterConfig);
@@ -72,7 +81,7 @@ function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimite
* Internally looks up the plan type, but doesn't expose it to callers.
*/
export async function getBatchLimits(
organization: Organization
organization: OrganizationForBatchLimits
): Promise<{ rateLimiter: RateLimiter; config: BatchLimitsConfig }> {
const rateLimiter = createOrganizationRateLimiter(organization);
const config = resolveBatchLimitsConfig(organization.batchQueueConcurrencyConfig);
+21 -39
View File
@@ -1,5 +1,4 @@
import { json } from "@remix-run/server-runtime";
import { type Prettify } from "@trigger.dev/core";
import { SignJWT, errors, jwtVerify } from "jose";
import { z } from "zod";
@@ -7,8 +6,11 @@ import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { findProjectByRef } from "~/models/project.server";
import {
authIncludeBase,
authIncludeWithParent,
findEnvironmentByApiKey,
findEnvironmentByPublicApiKey,
toAuthenticated,
} from "~/models/runtimeEnvironment.server";
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { logger } from "./logger.server";
@@ -23,7 +25,7 @@ import {
isOrganizationAccessToken,
} from "./organizationAccessToken.server";
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
import { sanitizeBranchName } from "~/v3/gitBranch";
import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
const ClaimsSchema = z.object({
scopes: z.array(z.string()).optional(),
@@ -36,12 +38,10 @@ const ClaimsSchema = z.object({
.optional(),
});
type Optional<T, K extends keyof T> = Prettify<Omit<T, K> & Partial<Pick<T, K>>>;
export type AuthenticatedEnvironment = Optional<
NonNullable<Awaited<ReturnType<typeof findEnvironmentByApiKey>>>,
"orgMember"
>;
// Re-export the slim shape defined in @trigger.dev/core. Single source of
// truth across the auth boundary (RBAC plugin contract → webapp handlers).
export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
export type ApiAuthenticationResult =
| ApiAuthenticationResultSuccess
@@ -52,7 +52,6 @@ export type ApiAuthenticationResultSuccess = {
apiKey: string;
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
environment: AuthenticatedEnvironment;
scopes?: string[];
oneTimeUse?: boolean;
realtime?: {
skipColumns?: string[];
@@ -163,7 +162,6 @@ export async function authenticateApiKey(
ok: true,
...result,
environment: validationResults.environment,
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
};
@@ -246,7 +244,6 @@ async function authenticateApiKeyWithFailure(
ok: true,
...result,
environment: validationResults.environment,
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
};
@@ -510,17 +507,14 @@ export async function authenticatedEnvironmentForAuthentication(
}
: {}),
},
include: {
project: true,
organization: true,
},
include: authIncludeBase,
});
if (!environment) {
throw json({ error: "Environment not found" }, { status: 404 });
}
return environment;
return toAuthenticated(environment);
}
const environment = await $replica.runtimeEnvironment.findFirst({
@@ -530,11 +524,7 @@ export async function authenticatedEnvironmentForAuthentication(
branchName: sanitizedBranch,
archivedAt: null,
},
include: {
project: true,
organization: true,
parentEnvironment: true,
},
include: authIncludeWithParent,
});
if (!environment) {
@@ -545,12 +535,13 @@ export async function authenticatedEnvironmentForAuthentication(
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
}
return {
// PREVIEW envs reuse the parent's apiKey for downstream auth flows
// (signed JWTs, internal-fetch helpers). Override before mapping so
// the slim shape carries the parent's key.
return toAuthenticated({
...environment,
apiKey: environment.parentEnvironment.apiKey,
organization: environment.organization,
project: environment.project,
};
});
}
case "organizationAccessToken": {
const organization = await $replica.organization.findUnique({
@@ -582,17 +573,14 @@ export async function authenticatedEnvironmentForAuthentication(
projectId: project.id,
slug: slug,
},
include: {
project: true,
organization: true,
},
include: authIncludeBase,
});
if (!environment) {
throw json({ error: "Environment not found" }, { status: 404 });
}
return environment;
return toAuthenticated(environment);
}
const environment = await $replica.runtimeEnvironment.findFirst({
@@ -602,11 +590,7 @@ export async function authenticatedEnvironmentForAuthentication(
branchName: sanitizedBranch,
archivedAt: null,
},
include: {
project: true,
organization: true,
parentEnvironment: true,
},
include: authIncludeWithParent,
});
if (!environment) {
@@ -617,12 +601,10 @@ export async function authenticatedEnvironmentForAuthentication(
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
}
return {
return toAuthenticated({
...environment,
apiKey: environment.parentEnvironment.apiKey,
organization: environment.organization,
project: environment.project,
};
});
}
default: {
auth satisfies never;
@@ -1,113 +0,0 @@
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments", "inputStreams", "query", "prompts", "sessions"] as const;
export type AuthorizationResources = {
[key in (typeof ResourceTypes)[number]]?: string | string[];
};
export type AuthorizationEntity = {
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
scopes?: string[];
};
/**
* Checks if the given entity is authorized to perform a specific action on a resource.
*
* @param entity - The entity requesting authorization.
* @param action - The action the entity wants to perform.
* @param resource - The resource on which the action is to be performed.
* @param superScopes - An array of super scopes that can bypass the normal authorization checks.
*
* @example
*
* ```typescript
* import { checkAuthorization } from "./authorization.server";
*
* const entity = {
* type: "PUBLIC",
* scope: ["read:runs:run_1234", "read:tasks"]
* };
*
* checkAuthorization(entity, "read", { runs: "run_1234" }); // Returns true
* checkAuthorization(entity, "read", { runs: "run_5678" }); // Returns false
* checkAuthorization(entity, "read", { tasks: "task_1234" }); // Returns true
* checkAuthorization(entity, "read", { tasks: ["task_5678"] }); // Returns true
* ```
*/
export type AuthorizationResult = { authorized: true } | { authorized: false; reason: string };
/**
* Checks if the given entity is authorized to perform a specific action on a resource.
*/
export function checkAuthorization(
entity: AuthorizationEntity,
action: AuthorizationAction,
resource: AuthorizationResources,
superScopes?: string[]
): AuthorizationResult {
// "PRIVATE" is a secret key and has access to everything
if (entity.type === "PRIVATE") {
return { authorized: true };
}
// "PUBLIC" is a deprecated key and has no access
if (entity.type === "PUBLIC") {
return { authorized: false, reason: "PUBLIC type is deprecated and has no access" };
}
// If the entity has no permissions, deny access
if (!entity.scopes || entity.scopes.length === 0) {
return {
authorized: false,
reason:
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
};
}
// If the resource object is empty, deny access
if (Object.keys(resource).length === 0) {
return { authorized: false, reason: "Resource object is empty" };
}
// Check for any of the super scopes
if (superScopes && superScopes.length > 0) {
if (superScopes.some((permission) => entity.scopes?.includes(permission))) {
return { authorized: true };
}
}
const filteredResource = Object.keys(resource).reduce((acc, key) => {
if (ResourceTypes.includes(key)) {
acc[key as keyof AuthorizationResources] = resource[key as keyof AuthorizationResources];
}
return acc;
}, {} as AuthorizationResources);
// Check each resource type
for (const [resourceType, resourceValue] of Object.entries(filteredResource)) {
const resourceValues = Array.isArray(resourceValue) ? resourceValue : [resourceValue];
for (const value of resourceValues) {
// Check for specific resource permission
const specificPermission = `${action}:${resourceType}:${value}`;
// Check for general resource type permission
const generalPermission = `${action}:${resourceType}`;
// If any permission matches, return authorized
if (entity.scopes.includes(specificPermission) || entity.scopes.includes(generalPermission)) {
return { authorized: true };
}
}
}
// No matching permissions found
return {
authorized: false,
reason: `Public Access Token is missing required permissions. Token has the following permissions: ${entity.scopes
.map((s) => `'${s}'`)
.join(
", "
)}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
};
}
-10
View File
@@ -4,7 +4,6 @@ import type { SendEmailOptions } from "remix-auth-email-link";
import { redirect } from "remix-typedjson";
import { env } from "~/env.server";
import type { AuthUser } from "./authUser";
import { commonWorker } from "~/v3/commonWorker.server";
import { logger } from "./logger.server";
import { singleton } from "~/utils/singleton";
import { assertEmailAllowed } from "~/utils/email";
@@ -92,15 +91,6 @@ export async function sendPlainTextEmail(options: SendPlainTextOptions) {
return client.sendPlainText(options);
}
export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: number }) {
const availableAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined;
await commonWorker.enqueue({
job: "scheduleEmail",
payload: data,
availableAt,
});
}
export async function sendEmail(data: DeliverEmail) {
return client.send(data);
}
@@ -7,7 +7,7 @@ import { createHash } from "@better-auth/utils/hash";
import { createOTP } from "@better-auth/utils/otp";
import { base32 } from "@better-auth/utils/base32";
import { z } from "zod";
import { scheduleEmail } from "../email.server";
import { scheduleEmail } from "../scheduleEmail.server";
const generateRandomString = createRandomStringGenerator("A-Z", "0-9");
@@ -3,6 +3,7 @@ import { customAlphabet, nanoid } from "nanoid";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { rbac } from "./rbac.server";
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
import { env } from "~/env.server";
@@ -19,6 +20,12 @@ export const PAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000;
type CreatePersonalAccessTokenOptions = {
name: string;
userId: string;
// Optional: when provided, persist a TokenRole row alongside the PAT
// so PAT-authenticated requests pick up that role's permissions
// (TRI-8749). The dashboard tokens page passes a chosen system role;
// the CLI auth-code path doesn't pass one (legacy behaviour
// preserved — those PATs run with no explicit role).
roleId?: string;
};
/** Returns obfuscated access tokens that aren't revoked */
@@ -105,6 +112,43 @@ export type PersonalAccessTokenAuthenticationResult = {
userId: string;
};
/**
* Smart-skip the `lastAccessedAt` write when the cached value is already
* within the throttle window. Saves one DB roundtrip per "fresh" auth.
*
* Two layers of throttling: JS-side (`Date.now() - lastAccessedAt.getTime()`)
* elides the SQL entirely when the caller already has a recent timestamp;
* SQL-side `WHERE` clause inside the `updateMany` guards against concurrent
* auths racing to a double-write when the JS check decides to fire.
*
* Called from both the legacy PAT flow (`authenticatePersonalAccessToken`)
* and the apiBuilder's RBAC-routed PAT flow (which gets `lastAccessedAt`
* from `rbac.authenticatePat`'s result). Keeps the throttle policy in one
* place rather than expecting every plugin implementer to re-implement it.
*/
export async function updateLastAccessedAtIfStale(
tokenId: string,
lastAccessedAt: Date | null
): Promise<void> {
if (
lastAccessedAt &&
Date.now() - lastAccessedAt.getTime() <= PAT_LAST_ACCESSED_THROTTLE_MS
) {
return; // fresh — no roundtrip
}
await prisma.personalAccessToken.updateMany({
where: {
id: tokenId,
revokedAt: null,
OR: [
{ lastAccessedAt: null },
{ lastAccessedAt: { lt: new Date(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS) } },
],
},
data: { lastAccessedAt: new Date() },
});
}
const EncryptedSecretValueSchema = z.object({
nonce: z.string(),
ciphertext: z.string(),
@@ -211,24 +255,7 @@ export async function authenticatePersonalAccessToken(
return;
}
// Conditional updateMany — only writes if the existing lastAccessedAt is
// null or older than the throttle window. The WHERE runs inside the UPDATE
// so concurrent auths don't race into a double-write. `revokedAt: null`
// matches the findFirst guard above so a token revoked between the read
// and write doesn't get a stale lastAccessedAt update.
await prisma.personalAccessToken.updateMany({
where: {
id: personalAccessToken.id,
revokedAt: null,
OR: [
{ lastAccessedAt: null },
{ lastAccessedAt: { lt: new Date(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS) } },
],
},
data: {
lastAccessedAt: new Date(),
},
});
await updateLastAccessedAtIfStale(personalAccessToken.id, personalAccessToken.lastAccessedAt);
const decryptedToken = decryptPersonalAccessToken(personalAccessToken);
@@ -338,6 +365,7 @@ export async function createPersonalAccessTokenFromAuthorizationCode(
export async function createPersonalAccessToken({
name,
userId,
roleId,
}: CreatePersonalAccessTokenOptions) {
const token = createToken();
const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY);
@@ -352,6 +380,45 @@ export async function createPersonalAccessToken({
},
});
// Persist the role choice via the RBAC plugin's setTokenRole. The
// plugin may store this in a separate datastore from Prisma (e.g.
// Drizzle on a different schema), so co-transactional inserts are
// awkward — we use a compensating-delete pattern instead: if
// setTokenRole fails, roll back the PAT row by deleting it. The auth
// path treats "no role" as permissive (matches the default fallback)
// so a brief orphan window between the two writes is harmless. The
// compensating delete narrows that window from "until manual cleanup"
// to "until the request returns".
//
// Skip the call entirely when no RBAC plugin is loaded — the OSS
// fallback has no TokenRole table to write to. Gating on
// `rbac.isUsingPlugin()` (rather than parsing the fallback's error
// string) keeps the OSS-vs-cloud branch explicit and decoupled from
// any specific error message.
if (roleId && (await rbac.isUsingPlugin())) {
const roleResult = await rbac.setTokenRole({
tokenId: personalAccessToken.id,
roleId,
});
if (!roleResult.ok) {
await prisma.personalAccessToken
.delete({ where: { id: personalAccessToken.id } })
.catch((err) => {
logger.error("Failed to compensating-delete PAT after TokenRole insert failed", {
patId: personalAccessToken.id,
roleResultError: roleResult.error,
deleteError: err instanceof Error ? err.message : String(err),
});
});
throw new Error(`Failed to assign role to access token: ${roleResult.error}`);
}
} else if (roleId) {
logger.debug("createPersonalAccessToken: no RBAC plugin, skipping role assignment", {
patId: personalAccessToken.id,
userId,
});
}
return {
id: personalAccessToken.id,
name,
+2 -30
View File
@@ -1,5 +1,5 @@
import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3";
import type { Organization, Project, RuntimeEnvironmentType } from "@trigger.dev/database";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import {
BillingClient,
defaultMachine as defaultMachineFromPlatform,
@@ -25,7 +25,6 @@ import { redirect } from "remix-typedjson";
import { z } from "zod";
import { env } from "~/env.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { createEnvironment } from "~/models/organization.server";
import { logger } from "~/services/logger.server";
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
import { singleton } from "~/utils/singleton";
@@ -598,33 +597,6 @@ export async function getEntitlement(
return result.val;
}
export async function projectCreated(
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">,
project: Project
) {
if (!isCloud()) {
await createEnvironment({ organization, project, type: "STAGING" });
await createEnvironment({
organization,
project,
type: "PREVIEW",
isBranchableEnvironment: true,
});
} else {
//staging is only available on certain plans
const plan = await getCurrentPlan(organization.id);
if (plan?.v3Subscription.plan?.limits.hasStagingEnvironment) {
await createEnvironment({ organization, project, type: "STAGING" });
await createEnvironment({
organization,
project,
type: "PREVIEW",
isBranchableEnvironment: true,
});
}
}
}
export async function getBillingAlerts(
organizationId: string
): Promise<BillingAlertsResult | undefined> {
@@ -789,7 +761,7 @@ export async function triggerInitialDeployment(
}
}
function isCloud(): boolean {
export function isCloud(): boolean {
const acceptableHosts = [
"https://cloud.trigger.dev",
"https://test-cloud.trigger.dev",
@@ -0,0 +1,35 @@
import type { Organization, Project } from "@trigger.dev/database";
import { createEnvironment } from "~/models/organization.server";
import { getCurrentPlan, isCloud } from "~/services/platform.v3.server";
// Extracted from platform.v3.server.ts to break a circular import:
// platform.v3.server ↔ models/organization.server (via createEnvironment).
// The cycle caused the bundled __esm wrappers to re-enter and short-circuit
// the platform.v3.server init, leaving `defaultMachine` and `machines`
// undefined in `singleton("machinePresets", ...)` — the boot crash at
// `allMachines()` traced to TRI-8731.
export async function projectCreated(
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">,
project: Project
) {
if (!isCloud()) {
await createEnvironment({ organization, project, type: "STAGING" });
await createEnvironment({
organization,
project,
type: "PREVIEW",
isBranchableEnvironment: true,
});
} else {
const plan = await getCurrentPlan(organization.id);
if (plan?.v3Subscription?.plan?.limits?.hasStagingEnvironment) {
await createEnvironment({ organization, project, type: "STAGING" });
await createEnvironment({
organization,
project,
type: "PREVIEW",
isBranchableEnvironment: true,
});
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import { $replica, prisma } from "~/db.server";
import type { PrismaClient } from "@trigger.dev/database";
import plugin from "@trigger.dev/rbac";
import { env } from "~/env.server";
// plugin.create() is synchronous — returns a lazy controller that resolves
// any installed RBAC plugin on first call. Top-level await is not used
// because CJS output format does not support it.
//
// Auth-path reads run on every request — pass the replica explicitly so
// they don't pile up on the primary. Writes (role mutations) still go
// through the primary. Same separation findEnvironmentByApiKey used
// before this PR moved bearer auth into the RBAC plugin.
//
// Session-cookie userId resolution lives at the call site (see
// dashboardBuilder.server.ts), not here. Statically importing
// `~/services/session.server` from this module dragged the entire
// remix-auth pipeline (auth.server → emailAuth/gitHubAuth/googleAuth,
// each validating their secret at module load) into anything that
// transitively imported `rbac` — including PAT auth callers that have
// no session-cookie path at all. Passing userId through the
// `authenticateSession` context decouples the plugin host from the
// host's session implementation.
export const rbac = plugin.create(
// $replica is structurally a PrismaClient minus `$transaction` — the
// RBAC fallback only uses `findFirst` on it, so the cast is safe.
{ primary: prisma, replica: $replica as PrismaClient },
{ forceFallback: env.RBAC_FORCE_FALLBACK }
);
@@ -126,9 +126,7 @@ export function isPublicJWT(token: string): boolean {
}
}
export function extractJwtSigningSecretKey(
environment: AuthenticatedEnvironment & { parentEnvironment?: { apiKey: string } }
) {
export function extractJwtSigningSecretKey(environment: AuthenticatedEnvironment) {
return environment.parentEnvironment?.apiKey ?? environment.apiKey;
}
@@ -1,20 +1,14 @@
import { z } from "zod";
import {
ApiAuthenticationResultSuccess,
authenticateApiRequestWithFailure,
} from "../apiAuth.server";
import { ApiAuthenticationResultSuccess } from "../apiAuth.server";
import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { fromZodError } from "zod-validation-error";
import { apiCors } from "~/utils/apiCors";
import {
AuthorizationAction,
AuthorizationResources,
checkAuthorization,
} from "../authorization.server";
import { logger } from "../logger.server";
import { rbac } from "../rbac.server";
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
import {
authenticateApiRequestWithPersonalAccessToken,
PersonalAccessTokenAuthenticationResult,
updateLastAccessedAtIfStale,
} from "../personalAccessToken.server";
import { safeJsonParse } from "~/utils/json";
import {
@@ -50,8 +44,126 @@ function logBoundaryError(
}
}
// Bridges the RBAC plugin (source of truth for auth + abilities) to the legacy
// ApiAuthenticationResultSuccess shape route handlers still expect. All three
// apiBuilder call sites funnel through this helper — no handler-level changes
// needed.
async function authenticateRequestForApiBuilder(
request: Request,
{ allowJWT }: { allowJWT: boolean }
): Promise<
| { ok: false; status: 401 | 403; error: string }
| { ok: true; authentication: ApiAuthenticationResultSuccess; ability: RbacAbility }
> {
const result = await rbac.authenticateBearer(request, { allowJWT });
if (!result.ok) {
// Plugin auth distinguishes 401 (who are you?) from 403 (you're not
// allowed) — e.g. a suspended account or IP block returns 403.
// Forwarding the status preserves that semantic for client retry logic.
return { ok: false, status: result.status, error: result.error };
}
// Plugins return the full AuthenticatedEnvironment shape directly — no
// follow-up DB lookup. The fallback fetches via Prisma, the cloud plugin
// via Drizzle; both produce the same slim contract type.
const authentication: ApiAuthenticationResultSuccess = {
ok: true,
apiKey: result.environment.apiKey,
type: result.subject.type === "publicJWT" ? "PUBLIC_JWT" : "PRIVATE",
environment: result.environment,
realtime: result.jwt?.realtime,
oneTimeUse: result.jwt?.oneTimeUse,
};
return { ok: true, authentication, ability: result.ability };
}
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
// A multi-resource auth check has two possible directions, and route authors
// have to pick one explicitly:
//
// - `anyResource(...)` — succeed if *any* element passes. Used when a single
// record carries multiple identifiers (a run is addressable by friendlyId /
// batch / tags / task) so a JWT scoped to *any* of them grants access.
//
// - `everyResource(...)` — succeed only if *every* element passes. Used for
// batch operations where each element is a *distinct* resource and a JWT
// scoped to one element must not authorize the others.
//
// Bare `RbacResource[]` is intentionally *not* part of `AuthResource` — the
// type system forces every multi-resource site to disambiguate. The original
// pre-RBAC apiBuilder had a separate `superScopes: [...]` whitelist for
// "broader-than-this-resource" access; post-RBAC that's expressed via the JWT
// ability's wildcard branches (`*:all` and `admin*` — see
// `internal-packages/rbac/src/ability.ts`) plus a collection-level shape
// `{ type: "<subject>" }` (no id) in the `anyResource` array so a
// `<action>:<subject>` JWT matches it. No code knob needed.
//
// Markers are Symbols so they can't collide with arbitrary RbacResource fields.
const ANY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.anyResource");
const EVERY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.everyResource");
type AnyResourceAuth = {
readonly [ANY_RESOURCE_MARKER]: true;
readonly resources: readonly RbacResource[];
};
type EveryResourceAuth = {
readonly [EVERY_RESOURCE_MARKER]: true;
readonly resources: readonly RbacResource[];
};
export function anyResource(resources: RbacResource[]): AnyResourceAuth {
return { [ANY_RESOURCE_MARKER]: true, resources };
}
export function everyResource(resources: RbacResource[]): EveryResourceAuth {
return { [EVERY_RESOURCE_MARKER]: true, resources };
}
function isAnyResource(value: unknown): value is AnyResourceAuth {
return (
typeof value === "object" &&
value !== null &&
(value as Record<symbol, unknown>)[ANY_RESOURCE_MARKER] === true
);
}
function isEveryResource(value: unknown): value is EveryResourceAuth {
return (
typeof value === "object" &&
value !== null &&
(value as Record<symbol, unknown>)[EVERY_RESOURCE_MARKER] === true
);
}
type AuthResource = RbacResource | AnyResourceAuth | EveryResourceAuth;
function checkAuth(
ability: RbacAbility,
action: string,
resource: AuthResource
): boolean {
if (isEveryResource(resource)) {
// Empty array via [].every() is vacuously true — would let any token
// pass auth. Routes building everyResource() from request bodies
// (e.g. batch trigger items) should never produce zero elements
// because body validation rejects empty arrays first, but defending
// here anyway since the auth layer should never grant on no input.
if (resource.resources.length === 0) return false;
return resource.resources.every((r) => ability.can(action, r));
}
if (isAnyResource(resource)) {
// Symmetric guard: anyResource([]) is benign for most abilities
// (.some() is false on empty), but the permissive ability would
// still grant. Treat empty as "no resource declared" → deny.
if (resource.resources.length === 0) return false;
return ability.can(action, [...resource.resources]);
}
return ability.can(action, resource);
}
type ApiKeyRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
@@ -76,7 +188,7 @@ type ApiKeyRouteBuilderOptions<
) => Promise<TResource | undefined>;
shouldRetryNotFound?: boolean;
authorization?: {
action: AuthorizationAction;
action: string;
resource: (
resource: NonNullable<TResource>,
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
@@ -90,8 +202,7 @@ type ApiKeyRouteBuilderOptions<
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined
) => AuthorizationResources;
superScopes?: string[];
) => AuthResource;
};
};
@@ -144,23 +255,15 @@ export function createLoaderApiRoute<
}
try {
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
if (!authenticationResult) {
const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
if (!authResult.ok) {
return await wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
if (!authenticationResult.ok) {
return await wrapResponse(
request,
json({ error: authenticationResult.error }, { status: 401 }),
json({ error: authResult.error }, { status: authResult.status }),
corsStrategy !== "none"
);
}
const { authentication: authenticationResult, ability } = authResult;
let parsedParams: any = undefined;
if (paramsSchema) {
@@ -227,7 +330,7 @@ export function createLoaderApiRoute<
}
if (authorization) {
const { action, resource: authResource, superScopes } = authorization;
const { action, resource: authResource } = authorization;
const $authResource = authResource(
resource,
parsedParams,
@@ -235,26 +338,12 @@ export function createLoaderApiRoute<
parsedHeaders
);
logger.debug("Checking authorization", {
action,
resource: $authResource,
superScopes,
scopes: authenticationResult.scopes,
});
const authorizationResult = checkAuthorization(
authenticationResult,
action,
$authResource,
superScopes
);
if (!authorizationResult.authorized) {
if (!checkAuth(ability, action, $authResource)) {
return await wrapResponse(
request,
json(
{
error: `Unauthorized: ${authorizationResult.reason}`,
error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
@@ -309,6 +398,37 @@ type PATRouteBuilderOptions<
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
corsStrategy?: "all" | "none";
// Resolves the target org/project for the request. Fed to
// `rbac.authenticatePat` so the plugin can compute the user's role
// floor (their authority in that org) for the cap intersection.
// When omitted, the PAT runs in identity-only mode — no role floor,
// no per-route ability gating beyond what authorization (if any)
// declares against a permissive baseline. Routes added before TRI-9087
// run in this mode by default.
context?: (
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
request: Request
) =>
| { organizationId?: string; projectId?: string }
| Promise<{ organizationId?: string; projectId?: string }>;
authorization?: {
action: string;
resource: (
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined
) => AuthResource;
};
};
type PATHandlerFunction<
@@ -328,6 +448,7 @@ type PATHandlerFunction<
? z.infer<THeadersSchema>
: undefined;
authentication: PersonalAccessTokenAuthenticationResult;
ability: RbacAbility;
request: Request;
apiVersion: API_VERSIONS;
}) => Promise<Response>;
@@ -346,6 +467,8 @@ export function createLoaderPATApiRoute<
searchParams: searchParamsSchema,
headers: headersSchema,
corsStrategy = "none",
context: contextFn,
authorization,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
@@ -353,16 +476,6 @@ export function createLoaderPATApiRoute<
}
try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return await wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
@@ -415,11 +528,70 @@ export function createLoaderPATApiRoute<
const apiVersion = getApiVersion(request);
// Single PAT auth roundtrip. `rbac.authenticatePat` validates the
// token AND computes the cap-and-floor ability in one DB query
// (the OSS fallback does the validation only and returns a
// permissive ability; the cloud plugin returns the joined
// cap/floor result). We previously called
// `authenticateApiRequestWithPersonalAccessToken` here first as
// belt-and-braces, but that meant two PAT lookups per request
// for routes with `context`/`authorization` declared. Routes
// without those still get a working `authentication` object —
// we pass an empty ctx and the fallback validates fine.
//
// `lastAccessedAt` is plumbed through the plugin result so the
// host can decide whether to fire the update (smart-skip in
// `updateLastAccessedAtIfStale` — no DB roundtrip when the
// cached timestamp is fresher than the throttle window).
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
const patAuth = await rbac.authenticatePat(request, ctx);
if (!patAuth.ok) {
return await wrapResponse(
request,
json({ error: patAuth.error }, { status: patAuth.status }),
corsStrategy !== "none"
);
}
const authenticationResult: PersonalAccessTokenAuthenticationResult = {
userId: patAuth.userId,
};
const ability: RbacAbility = patAuth.ability;
// Fire the `lastAccessedAt` write conditionally. Two-layer throttle:
// JS skips the SQL when the value is fresh (most requests); the
// SQL `WHERE` clause inside the helper is race-safe for concurrent
// auths that both decide to fire. Don't `await` it from the
// critical path? — it's a one-row update on a small hot table and
// we want to surface failures, so it's awaited (same shape as the
// legacy `authenticatePersonalAccessToken`).
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
if (authorization) {
const $resource = authorization.resource(parsedParams, parsedSearchParams, parsedHeaders);
if (!checkAuth(ability, authorization.action, $resource)) {
return await wrapResponse(
request,
json(
{
error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 }
),
corsStrategy !== "none"
);
}
}
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
ability,
request,
apiVersion,
});
@@ -468,7 +640,7 @@ type ApiKeyActionRouteBuilderOptions<
: undefined
) => Promise<TResource | undefined>;
authorization?: {
action: AuthorizationAction;
action: string;
resource: (
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
@@ -490,8 +662,7 @@ type ApiKeyActionRouteBuilderOptions<
// externalId for sessions) read it here so a JWT minted for either form
// authorizes both URL forms.
resource: TResource | undefined
) => AuthorizationResources;
superScopes?: string[];
) => AuthResource;
};
maxContentLength?: number;
body?: TBodySchema;
@@ -579,23 +750,15 @@ export function createActionApiRoute<
}
try {
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
if (!authenticationResult) {
const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
if (!authResult.ok) {
return await wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
if (!authenticationResult.ok) {
return await wrapResponse(
request,
json({ error: authenticationResult.error }, { status: 401 }),
json({ error: authResult.error }, { status: authResult.status }),
corsStrategy !== "none"
);
}
const { authentication: authenticationResult, ability } = authResult;
if (maxContentLength) {
const contentLength = request.headers.get("content-length");
@@ -706,7 +869,7 @@ export function createActionApiRoute<
// - PRIVATE key + missing resource → auth passes → 404 (correct)
// - PRIVATE key + existing resource → auth passes → handler runs
if (authorization) {
const { action, resource: authResource, superScopes } = authorization;
const { action, resource: authResource } = authorization;
const $resource = authResource(
parsedParams,
parsedSearchParams,
@@ -715,26 +878,12 @@ export function createActionApiRoute<
resource
);
logger.debug("Checking authorization", {
action,
resource: $resource,
superScopes,
scopes: authenticationResult.scopes,
});
const authorizationResult = checkAuthorization(
authenticationResult,
action,
$resource,
superScopes
);
if (!authorizationResult.authorized) {
if (!checkAuth(ability, action, $resource)) {
return await wrapResponse(
request,
json(
{
error: `Unauthorized: ${authorizationResult.reason}`,
error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
@@ -825,9 +974,8 @@ type MultiMethodApiRouteOptions<
allowJWT?: boolean;
corsStrategy?: "all" | "none";
authorization?: {
action: AuthorizationAction;
resource: (params: InferZod<TParamsSchema>) => AuthorizationResources;
superScopes?: string[];
action: string;
resource: (params: InferZod<TParamsSchema>) => AuthResource;
};
maxContentLength?: number;
methods: Partial<
@@ -872,33 +1020,22 @@ export function createMultiMethodApiRoute<
if (!methodConfig) {
return await wrapResponse(
request,
json(
{ error: "Method not allowed" },
{ status: 405, headers: { Allow: allowedMethods } }
),
json({ error: "Method not allowed" }, { status: 405, headers: { Allow: allowedMethods } }),
corsStrategy !== "none"
);
}
try {
// Authenticate
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
if (!authenticationResult) {
const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
if (!authResult.ok) {
return await wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
if (!authenticationResult.ok) {
return await wrapResponse(
request,
json({ error: authenticationResult.error }, { status: 401 }),
json({ error: authResult.error }, { status: authResult.status }),
corsStrategy !== "none"
);
}
const { authentication: authenticationResult, ability } = authResult;
if (maxContentLength) {
const contentLength = request.headers.get("content-length");
@@ -966,29 +1103,15 @@ export function createMultiMethodApiRoute<
// Authorize
if (authorization) {
const { action, resource, superScopes } = authorization;
const { action, resource } = authorization;
const $resource = resource(parsedParams);
logger.debug("Checking authorization", {
action,
resource: $resource,
superScopes,
scopes: authenticationResult.scopes,
});
const authorizationResult = checkAuthorization(
authenticationResult,
action,
$resource,
superScopes
);
if (!authorizationResult.authorized) {
if (!checkAuth(ability, action, $resource)) {
return await wrapResponse(
request,
json(
{
error: `Unauthorized: ${authorizationResult.reason}`,
error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
@@ -0,0 +1,117 @@
// Server-only impl backing dashboardBuilder.ts. Imports rbac.server and
// runs the actual auth/authorization. The wrappers in dashboardBuilder.ts
// dynamic-import this module from inside the loader/action body, so it
// never reaches the client bundle.
import { json, redirect } from "@remix-run/server-runtime";
import type { RbacAbility } from "@trigger.dev/rbac";
import { rbac } from "~/services/rbac.server";
import { getUserId } from "~/services/session.server";
import type {
AuthorizationOption,
DashboardLoaderOptions,
SessionUser,
} from "./dashboardBuilder";
import { fromZodError } from "zod-validation-error";
import type { z } from "zod";
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
function loginRedirectFor(request: Request, override?: string): Response {
if (override) return redirect(override);
const url = new URL(request.url);
const redirectTo = encodeURIComponent(`${url.pathname}${url.search}`);
return redirect(`/login?redirectTo=${redirectTo}`);
}
function isAuthorized(ability: RbacAbility, authorization: AuthorizationOption): boolean {
if ("requireSuper" in authorization) {
return ability.canSuper();
}
return ability.can(authorization.action, authorization.resource);
}
type AuthScope = { organizationId?: string; projectId?: string };
export async function authenticateAndAuthorize<
TParams,
TSearchParams,
TContext extends AuthScope
>(
request: Request,
rawParams: unknown,
options: DashboardLoaderOptions<TParams, TSearchParams, TContext>
): Promise<
| { ok: false; response: Response }
| {
ok: true;
user: SessionUser;
ability: RbacAbility;
params: unknown;
searchParams: unknown;
context: TContext;
}
> {
let parsedParams: any = undefined;
if (options.params) {
const parsed = (options.params as unknown as AnyZodSchema).safeParse(rawParams);
if (!parsed.success) {
return {
ok: false,
response: json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
};
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (options.searchParams) {
const fromUrl = Object.fromEntries(new URL(request.url).searchParams);
const parsed = (options.searchParams as unknown as AnyZodSchema).safeParse(fromUrl);
if (!parsed.success) {
return {
ok: false,
response: json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
};
}
parsedSearchParams = parsed.data;
}
const ctx = (options.context
? await options.context(parsedParams, request)
: ({} as TContext)) as TContext;
// Resolve userId from the session cookie *here* (the dashboard
// request boundary) and feed it into the rbac plugin context. The
// plugin no longer takes a `helpers.getSessionUserId` callback —
// statically importing session.server from rbac.server dragged the
// entire remix-auth strategy chain (each strategy validates its
// secret at module load) into anything that pulled `rbac` in,
// including PAT-only callers.
const userId = (await getUserId(request)) ?? null;
const auth = await rbac.authenticateSession(request, { ...ctx, userId });
if (!auth.ok) {
if (auth.reason === "unauthenticated") {
return { ok: false, response: loginRedirectFor(request, options.loginRedirect) };
}
return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") };
}
if (options.authorization && !isAuthorized(auth.ability, options.authorization)) {
return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") };
}
return {
ok: true,
user: auth.user,
ability: auth.ability,
params: parsedParams,
searchParams: parsedSearchParams,
context: ctx,
};
}
@@ -0,0 +1,141 @@
// Client-safe shim for the dashboard route builder. The actual server
// implementation lives in dashboardBuilder.server.ts; the wrappers here
// just return closures that lazily import that impl on first invocation.
//
// Why split: routes use `export const loader = dashboardLoader(...)` at
// module top-level. Remix's dev build preserves the top-level call when
// resolving the loader export, so the import target needs to exist on
// the client even though the closure body never executes there. A
// `.server.ts` file is excluded from the client bundle, which would
// resolve `dashboardLoader` to undefined and crash with
// "dashboardLoader is not a function" on first navigation. Keeping this
// file non-`.server` puts the wrappers in the client bundle as
// effectively no-op closures (they're never called there), and the
// closure body's dynamic import only resolves at server runtime.
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
import type { z } from "zod";
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
type InferZod<T> = T extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<T>
: undefined;
export type SessionUser = {
id: string;
email: string;
name: string | null;
displayName: string | null;
avatarUrl: string | null;
admin: boolean;
confirmedBasicDetails: boolean;
isImpersonating: boolean;
};
// `requireSuper: true` enforces ability.canSuper(). Otherwise an explicit
// action + resource pair is checked via ability.can(...).
export type AuthorizationOption =
| { requireSuper: true }
| {
action: string;
resource: RbacResource | RbacResource[];
};
// Plugin-side scope: whatever the route's `context` returns must include
// these (or just be `{}` when the route doesn't scope by org/project).
// rbac.authenticateSession reads them off the value to filter UserRole.
type AuthScope = { organizationId?: string; projectId?: string };
export type DashboardLoaderOptions<TParams, TSearchParams, TContext extends AuthScope> = {
params?: TParams;
searchParams?: TSearchParams;
// Resolves any per-request data the handler + auth check both need
// (typically org/project lookups from URL params). The returned object
// is fed to `rbac.authenticateSession` as the auth scope AND passed
// through to the handler in `args.context`, so the route does each
// lookup once.
context?: (
params: InferZod<TParams>,
request: Request
) => TContext | Promise<TContext>;
authorization?: AuthorizationOption;
// Where to send unauthenticated requests. Defaults to /login with a
// redirectTo back to the original path.
loginRedirect?: string;
// Where to send users who pass auth but fail the ability check. Defaults
// to "/" (the home page).
unauthorizedRedirect?: string;
};
export type DashboardLoaderHandlerArgs<TParams, TSearchParams, TContext> = {
params: InferZod<TParams>;
searchParams: InferZod<TSearchParams>;
user: SessionUser;
ability: RbacAbility;
context: TContext;
request: Request;
};
export function dashboardLoader<
TParams extends AnyZodSchema | undefined = undefined,
TSearchParams extends AnyZodSchema | undefined = undefined,
TContext extends AuthScope = AuthScope,
TReturn extends Response = Response
>(
options: DashboardLoaderOptions<TParams, TSearchParams, TContext>,
handler: (
args: DashboardLoaderHandlerArgs<TParams, TSearchParams, TContext>
) => Promise<TReturn>
) {
return async function loader({ request, params }: LoaderFunctionArgs): Promise<TReturn> {
// Server-only — see comment at top. Node caches the module after the
// first call, so the dynamic import is effectively free past warmup.
const { authenticateAndAuthorize } = await import("./dashboardBuilder.server");
const result = await authenticateAndAuthorize(request, params, options);
if (!result.ok) throw result.response;
return handler({
params: result.params as InferZod<TParams>,
searchParams: result.searchParams as InferZod<TSearchParams>,
user: result.user,
ability: result.ability,
context: result.context as TContext,
request,
});
};
}
export type DashboardActionOptions<TParams, TSearchParams, TContext extends AuthScope> =
DashboardLoaderOptions<TParams, TSearchParams, TContext>;
export type DashboardActionHandlerArgs<TParams, TSearchParams, TContext> =
DashboardLoaderHandlerArgs<TParams, TSearchParams, TContext>;
export function dashboardAction<
TParams extends AnyZodSchema | undefined = undefined,
TSearchParams extends AnyZodSchema | undefined = undefined,
TContext extends AuthScope = AuthScope,
TReturn extends Response = Response
>(
options: DashboardActionOptions<TParams, TSearchParams, TContext>,
handler: (
args: DashboardActionHandlerArgs<TParams, TSearchParams, TContext>
) => Promise<TReturn>
) {
return async function action({ request, params }: ActionFunctionArgs): Promise<TReturn> {
const { authenticateAndAuthorize } = await import("./dashboardBuilder.server");
const result = await authenticateAndAuthorize(request, params, options);
if (!result.ok) throw result.response;
return handler({
params: result.params as InferZod<TParams>,
searchParams: result.searchParams as InferZod<TSearchParams>,
user: result.user,
ability: result.ability,
context: result.context as TContext,
request,
});
};
}
@@ -0,0 +1,16 @@
import type { DeliverEmail } from "emails";
import { commonWorker } from "~/v3/commonWorker.server";
// Lives outside email.server.ts so that the SMTP/Resend client module
// stays a leaf dependency. Pulling commonWorker from email.server poisoned
// every consumer of the auth chain (auth → emailAuth → email) with the
// V1+V2 worker tree, which transitively loads marqs and trips Redis-env
// guards in any vitest file whose import graph reaches it.
export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: number }) {
const availableAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined;
await commonWorker.enqueue({
job: "scheduleEmail",
payload: data,
availableAt,
});
}
@@ -3,7 +3,7 @@ import slug from "slug";
import { prisma } from "~/db.server";
import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server";
import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch";
import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import { logger } from "./logger.server";
import { getCurrentPlan, getLimit } from "./platform.v3.server";
+4
View File
@@ -114,6 +114,10 @@ export function organizationTeamPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/team`;
}
export function organizationRolesPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/roles`;
}
export function inviteTeamMemberPath(organization: OrgForPath) {
return `${organizationPath(organization)}/invite`;
}
@@ -1,4 +1,5 @@
import { Prisma, type PrismaClient, type RuntimeEnvironmentType } from "@trigger.dev/database";
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
import { z } from "zod";
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
import { $transaction, prisma } from "~/db.server";
@@ -876,8 +877,21 @@ export const RuntimeEnvironmentForEnvRepoPayload = {
},
} as const;
export type RuntimeEnvironmentForEnvRepo = Prisma.RuntimeEnvironmentGetPayload<
typeof RuntimeEnvironmentForEnvRepoPayload
// Derived from the slim AuthenticatedEnvironment so a full AE satisfies
// this type — the legacy Prisma payload had `builtInEnvironmentVariableOverrides`
// as Prisma's JsonValue, which is a subtype of `unknown` in the slim
// shape, causing assignability errors in the JWT/queue paths that pass
// AE values straight through. Using Pick<AE, ...> aligns them.
export type RuntimeEnvironmentForEnvRepo = Pick<
AuthenticatedEnvironment,
| "id"
| "slug"
| "type"
| "projectId"
| "apiKey"
| "organizationId"
| "branchName"
| "builtInEnvironmentVariableOverrides"
>;
export const environmentVariablesRepository = new EnvironmentVariablesRepository();
@@ -1333,10 +1347,13 @@ function resolveBuiltInEnvironmentVariableOverrides(
if (
!Array.isArray(overrides) &&
typeof overrides === "object" &&
key in overrides &&
typeof overrides[key] === "string"
overrides !== null &&
key in overrides
) {
return overrides[key];
const value = (overrides as Record<string, unknown>)[key];
if (typeof value === "string") {
return value;
}
}
return defaultValue;
@@ -1,13 +1,21 @@
import { depot } from "@depot/sdk-node";
import { type ExternalBuildData } from "@trigger.dev/core/v3";
import { type Project } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import pRetry from "p-retry";
import { logger } from "~/services/logger.server";
// Just the project columns this module reads — keeps the signature
// compatible with both the full Prisma `Project` payload and the slim
// `AuthenticatedEnvironment["project"]` shape.
type ProjectForBuilder = {
id: string;
externalRef: string;
builderProjectId: string | null;
};
export async function createRemoteImageBuild(
project: Project
project: ProjectForBuilder
): Promise<ExternalBuildData | undefined> {
if (!remoteBuildsEnabled()) {
return;
@@ -42,7 +50,7 @@ export async function createRemoteImageBuild(
};
}
async function createBuilderProjectIfNotExists(project: Project) {
async function createBuilderProjectIfNotExists(project: ProjectForBuilder) {
if (project.builderProjectId) {
return project.builderProjectId;
}
+1
View File
@@ -124,6 +124,7 @@
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@trigger.dev/rbac": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.27",
"@trigger.dev/redis-worker": "workspace:*",
+65
View File
@@ -0,0 +1,65 @@
# Webapp tests
Three suites live in this directory.
## Unit tests — `*.test.ts`
Run with `pnpm test` from `apps/webapp`. Default vitest pickup. No
container setup. Run on every PR via `unit-tests-webapp.yml`.
## Smoke e2e — `*.e2e.test.ts`
End-to-end auth baseline that proves the route auth plumbing is wired up.
Each file spins up its own webapp + Postgres + Redis container in
`beforeAll` (~30s startup). Vitest config: `vitest.e2e.config.ts`. Run on
every PR via `e2e-webapp.yml`.
```bash
cd apps/webapp
pnpm exec vitest --config vitest.e2e.config.ts
```
## Comprehensive auth e2e — `*.e2e.full.test.ts`
The full RBAC auth matrix — every route family with explicit pass/fail
scenarios. See TRI-8731 for the parent ticket and TRI-8732 onwards for
each family's coverage spec.
**Architecture**: one container reused across the whole suite via
`vitest.e2e.full.config.ts`'s `globalSetup`. Test files share the server
through `getTestServer()` from `helpers/sharedTestServer.ts`. Each test
seeds its own resources so order doesn't matter.
**Layout**:
| File | Top-level describe | Family subtasks |
|---|---|---|
| `auth-api.e2e.full.test.ts` | `API` | TRI-8733 trigger, TRI-8734 run resource, TRI-8735 run mutations, TRI-8736 run lists, TRI-8737 batches, TRI-8738 prompts, TRI-8739 deployments + query, TRI-8740 waitpoints + input streams, TRI-8741 PAT |
| `auth-dashboard.e2e.full.test.ts` | `Dashboard` | TRI-8742 admin pages |
| `auth-cross-cutting.e2e.full.test.ts` | `Cross-cutting` | TRI-8743 deleted projects / revoked keys / expired JWTs / env mismatch / force-fallback toggle |
**Adding a new family**: pick the relevant file, add a nested `describe`
block. Inside, seed your own fixtures via the helpers and hit the shared
server.
```ts
describe("Trigger task", () => {
const server = getTestServer();
it("missing Authorization → 401", async () => {
const res = await server.webapp.fetch("/api/v1/tasks/x/trigger", { method: "POST", body: "{}" });
expect(res.status).toBe(401);
});
});
```
**CI**: `e2e-webapp-auth-full.yml`. Triggers on `workflow_dispatch`,
nightly schedule, and PRs touching auth-relevant paths (route builders,
rbac.server.ts, apiAuth.server.ts, apiroutes, the suite itself).
**Run locally**:
```bash
cd apps/webapp
pnpm exec vitest --config vitest.e2e.full.config.ts
```
+306
View File
@@ -11,6 +11,9 @@ import type { TestServer } from "@internal/testcontainers/webapp";
import { startTestServer } from "@internal/testcontainers/webapp";
import { generateJWT } from "@trigger.dev/core/v3/jwt";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT";
import { seedTestRun } from "./helpers/seedTestRun";
import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint";
vi.setConfig({ testTimeout: 180_000 });
@@ -119,3 +122,306 @@ describe("JWT bearer auth — baseline behavior", () => {
expect(res.status).toBe(401);
});
});
// Exercises the RBAC plugin loader end-to-end. The test server boots
// with RBAC_FORCE_FALLBACK=1 (see internal-packages/testcontainers/src/webapp.ts),
// which makes rbac.server.ts use the default fallback regardless of
// whether a plugin is installed in node_modules. /admin/concurrency
// uses rbac.authenticateSession internally; an unauthenticated request
// must flow through LazyController → RoleBaseAccessFallback →
// redirect("/login").
describe("RBAC plugin — fallback wiring", () => {
it("unauthenticated dashboard route redirects to /login via the fallback", async () => {
const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" });
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(new URL(location, "http://placeholder").pathname).toBe("/login");
});
});
// Covers createActionApiRoute's bearer auth path. The target route is
// POST /api/v1/idempotencyKeys/:key/reset — allowJWT: true, superScopes: ["write:runs", "admin"].
// Tests assert HTTP-observable behavior so they remain valid after TRI-8719 swaps
// authenticateApiRequestWithFailure for rbac.authenticateBearer.
describe("API bearer auth — action requests", () => {
const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset";
it("valid API key: auth passes (body validation fails, not 401/403)", async () => {
const { apiKey } = await seedTestEnvironment(server.prisma);
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({}), // missing taskIdentifier → zod validation error
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it("missing Authorization header: 401", async () => {
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ taskIdentifier: "noop" }),
});
expect(res.status).toBe(401);
});
it("invalid API key: 401", async () => {
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: {
Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real",
"content-type": "application/json",
},
body: JSON.stringify({ taskIdentifier: "noop" }),
});
expect(res.status).toBe(401);
});
});
describe("JWT bearer auth — action requests", () => {
const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset";
it("JWT with matching scope: auth passes", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: ["write:runs"] });
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it("JWT with wrong scope (read-only) on write route: 403", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
const res = await server.webapp.fetch(targetPath, {
method: "POST",
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
body: JSON.stringify({ taskIdentifier: "noop" }),
});
expect(res.status).toBe(403);
});
});
// Covers createLoaderPATApiRoute via GET /api/v1/projects/:projectRef/runs.
// authenticateApiRequestWithPersonalAccessToken rejects anything that isn't tr_pat_-prefixed
// or doesn't match a non-revoked PersonalAccessToken row.
describe("Personal access token auth", () => {
const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`;
it("missing Authorization header: 401", async () => {
const res = await server.webapp.fetch(pathFor("nonexistent"));
expect(res.status).toBe(401);
});
it("API key (tr_dev_*) on PAT-only route: 401", async () => {
const { apiKey } = await seedTestEnvironment(server.prisma);
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: { Authorization: `Bearer ${apiKey}` },
});
expect(res.status).toBe(401);
});
it("malformed PAT (wrong prefix): 401", async () => {
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: { Authorization: "Bearer not_a_pat_at_all_random_string" },
});
expect(res.status).toBe(401);
});
it("well-formed but unknown PAT: 401", async () => {
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: {
Authorization: "Bearer tr_pat_0000000000000000000000000000000000000000",
},
});
expect(res.status).toBe(401);
});
it("revoked PAT: 401", async () => {
const user = await seedTestUser(server.prisma);
const { token } = await seedTestPAT(server.prisma, user.id, { revoked: true });
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(401);
});
it("valid PAT on nonexistent project: 404 (auth passes)", async () => {
const user = await seedTestUser(server.prisma);
const { token } = await seedTestPAT(server.prisma, user.id);
const res = await server.webapp.fetch(pathFor("nonexistent"), {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(404);
});
});
// Verifies resource-scoped JWT behaviour end-to-end against a real seeded resource.
// Target: POST /api/v1/waitpoints/tokens/:waitpointFriendlyId/complete — allowJWT: true,
// authorization: { action: "write", resource: (params) => ({ waitpoints: params.waitpointFriendlyId }),
// superScopes: ["write:waitpoints", "admin"] }.
//
// The Waitpoint is seeded with status COMPLETED so the handler short-circuits with
// { success: true } once auth passes — no run-engine worker needed. "Auth passes" is
// observable as a 200 response; "auth fails" is observable as a 403.
describe("JWT bearer auth — resource-scoped scopes", () => {
const pathFor = (friendlyId: string) => `/api/v1/waitpoints/tokens/${friendlyId}/complete`;
async function seedEnvAndWaitpoint() {
const seed = await seedTestEnvironment(server.prisma);
const waitpoint = await seedTestWaitpoint(server.prisma, {
environmentId: seed.environment.id,
projectId: seed.project.id,
});
return { ...seed, waitpoint };
}
async function completeRequest(friendlyId: string, jwt: string) {
return server.webapp.fetch(pathFor(friendlyId), {
method: "POST",
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
body: JSON.stringify({}),
});
}
it("scope matches exact resource id: 200", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, {
scopes: [`write:waitpoints:${waitpoint.friendlyId}`],
});
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(200);
});
it("scope targets a different resource id: 403", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, {
scopes: ["write:waitpoints:waitpoint_someoneelse000000000000000"],
});
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(403);
});
it("type-level scope (no id) grants all resources of that type: 200", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, { scopes: ["write:waitpoints"] });
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(200);
});
it("scope action mismatch (read-only on write route) with matching resource id: 403", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, {
scopes: [`read:waitpoints:${waitpoint.friendlyId}`],
});
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(403);
});
it("scope targets a different resource type: 403", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, {
scopes: ["write:runs:run_abc000000000000000000000"],
});
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(403);
});
it("admin super-scope grants access (legacy behaviour): 200", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, { scopes: ["admin"] });
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(200);
});
it("unrelated type scope with no super-scope match: 403", async () => {
const { environment, waitpoint } = await seedEnvAndWaitpoint();
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
const res = await completeRequest(waitpoint.friendlyId, jwt);
expect(res.status).toBe(403);
});
});
// Pre-migration coverage for the three behavioural constraints captured in TRI-8719.
// Each test locks in an observable current behaviour that the migration must preserve:
// - custom actions (trigger/batchTrigger/update) satisfied by write:* scopes
// - multi-key resource callbacks (runs/tags/batch/tasks) — any key match grants access
// - empty resource callbacks relying on superScopes
describe("JWT bearer auth — behaviours to preserve through TRI-8719", () => {
it("custom action: type-level write:tasks scope satisfies action=\"trigger\" (auth passes)", async () => {
const { environment } = await seedTestEnvironment(server.prisma);
// Current SDK + MCP JWTs for task-trigger use type-level scope, e.g. write:tasks.
// Legacy checkAuthorization passes via exact superScope match ["write:tasks", "admin"].
// After TRI-8719, the ACTION_ALIASES map must keep this working: trigger action is
// satisfied by a scope whose action is write.
const jwt = await generateTestJWT(environment, { scopes: ["write:tasks"] });
const res = await server.webapp.fetch("/api/v1/tasks/nonexistent-task/trigger", {
method: "POST",
headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it("multi-key resource: read:tags:<tag> scope grants access to a run carrying that tag (auth passes)", async () => {
const { environment, project } = await seedTestEnvironment(server.prisma);
const { runFriendlyId } = await seedTestRun(server.prisma, {
environmentId: environment.id,
projectId: project.id,
runTags: ["my-resource-scoped-tag"],
});
const jwt = await generateTestJWT(environment, {
scopes: ["read:tags:my-resource-scoped-tag"],
});
const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
it("multi-key resource: read:batch:<friendlyId> scope grants access to a run in that batch (auth passes)", async () => {
const { environment, project } = await seedTestEnvironment(server.prisma);
const { runFriendlyId, batchFriendlyId } = await seedTestRun(server.prisma, {
environmentId: environment.id,
projectId: project.id,
withBatch: true,
});
const jwt = await generateTestJWT(environment, {
scopes: [`read:batch:${batchFriendlyId}`],
});
const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
// Empty-resource routes (api.v1.batches.ts, api.v1.idempotencyKeys.$key.reset.ts)
// currently DENY all JWTs because legacy checkAuthorization's empty-resource check
// fires before the superScope check. TRI-8719's plan to add explicit { type: "runs" }
// changes this to "JWTs with read:runs or write:runs now work on these routes" — an
// intentional improvement, not a preserved behaviour. See TRI-8719 description for
// the note; there's nothing to lock in with a test here.
});
// Edge cases where auth-path DB state should cause 401 even with a valid-looking token.
describe("API bearer auth — environment/project edge cases", () => {
it("valid API key whose project is soft-deleted: 401", async () => {
const { apiKey, project } = await seedTestEnvironment(server.prisma);
await server.prisma.project.update({
where: { id: project.id },
data: { deletedAt: new Date() },
});
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
headers: { Authorization: `Bearer ${apiKey}` },
});
expect(res.status).toBe(401);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
// Cross-cutting auth-layer behaviours that aren't tied to a specific route
// family — see TRI-8743. Soft-deleted projects, revoked keys, expired JWTs,
// cross-env mismatch, force-fallback toggle.
//
// Strategy: pick one representative API-key route
// (GET /api/v1/runs/run_doesnotexist/result) and one representative JWT
// route (POST /api/v1/waitpoints/tokens/<id>/complete) and exercise the
// edge cases against those. The route choice doesn't matter — the
// auth layer is shared across every API route via apiBuilder.server.ts.
// Smoke matrix (api-auth.e2e.test.ts) already covers the trivial
// cases (missing/invalid key, basic JWT pass, soft-deleted project);
// this file adds cases that need explicit fixture setup.
import { generateJWT } from "@trigger.dev/core/v3/jwt";
import { SignJWT } from "jose";
import { describe, expect, it } from "vitest";
import { getTestServer } from "./helpers/sharedTestServer";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
describe("Cross-cutting", () => {
it("shared prisma client can read from the postgres container", async () => {
const server = getTestServer();
const count = await server.prisma.user.count();
expect(count).toBeGreaterThanOrEqual(0);
});
// The auth path falls back to RevokedApiKey when a key isn't found
// in RuntimeEnvironment — letting customers continue to use a key
// for a configurable grace window after rotation. See
// models/runtimeEnvironment.server.ts. The grace lookup matches by
// (apiKey AND expiresAt > now) and rehydrates the env via the FK.
describe("Revoked API key grace window", () => {
const route = "/api/v1/runs/run_doesnotexist/result";
it("revoked key within grace (expiresAt > now): auth passes", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
// Mint a fresh "rotated" key that doesn't exist on any env, then
// record it as recently revoked with a future grace expiry.
const rotatedKey = `tr_dev_rotated_${Math.random().toString(36).slice(2)}`;
await server.prisma.revokedApiKey.create({
data: {
apiKey: rotatedKey,
runtimeEnvironmentId: environment.id,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // +1 day
},
});
const res = await server.webapp.fetch(route, {
headers: { Authorization: `Bearer ${rotatedKey}` },
});
// Auth passed — the route's resource lookup just doesn't find
// run_doesnotexist. The point is NOT 401.
expect(res.status).not.toBe(401);
});
it("revoked key past grace (expiresAt < now): 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
const expiredKey = `tr_dev_expired_${Math.random().toString(36).slice(2)}`;
await server.prisma.revokedApiKey.create({
data: {
apiKey: expiredKey,
runtimeEnvironmentId: environment.id,
expiresAt: new Date(Date.now() - 60 * 1000), // -1 minute
},
});
const res = await server.webapp.fetch(route, {
headers: { Authorization: `Bearer ${expiredKey}` },
});
expect(res.status).toBe(401);
});
});
// JWT edge cases beyond what the smoke matrix covers (which only
// checks "wrong key" and "missing scope"). All target the same
// representative JWT route — the JWT validator is shared across
// routes via apiBuilder, so coverage here generalises.
describe("JWT edge cases", () => {
const route = "/api/v1/waitpoints/tokens/wp_does_not_exist/complete";
async function postWithJwt(jwt: string) {
const server = getTestServer();
return server.webapp.fetch(route, {
method: "POST",
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
}
it("JWT with expirationTime in the past: 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
// generateJWT only accepts string expirationTimes (relative, like
// "15m"). To create a definitively-expired token use jose
// directly with an absolute past timestamp.
const secret = new TextEncoder().encode(environment.apiKey);
const jwt = await new SignJWT({
pub: true,
sub: environment.id,
scopes: ["write:waitpoints"],
})
.setIssuer("https://id.trigger.dev")
.setAudience("https://api.trigger.dev")
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(0)
.setExpirationTime(1) // 1970-01-01 — definitively expired
.sign(secret);
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT with pub: false: 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: environment.apiKey,
payload: { pub: false, sub: environment.id, scopes: ["write:waitpoints"] },
expirationTime: "15m",
});
// pub: false means "this token isn't meant for client-side use"
// — the auth layer rejects it for the same-class JWT routes.
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT with no sub claim: 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: environment.apiKey,
payload: { pub: true, scopes: ["write:waitpoints"] },
expirationTime: "15m",
});
// No sub claim — auth can't resolve which env the token belongs
// to, so it must reject. (sub carries the env id.)
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT signed with another env's apiKey (cross-env): 401", async () => {
const server = getTestServer();
// env A's id but signed with env B's apiKey — sub-vs-signature
// mismatch the auth layer must catch.
const a = await seedTestEnvironment(server.prisma);
const b = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: b.apiKey, // <-- WRONG key relative to the sub claim
payload: { pub: true, sub: a.environment.id, scopes: ["write:waitpoints"] },
expirationTime: "15m",
});
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT malformed (three parts but invalid base64 in payload): 401", async () => {
// Three "."-separated parts so the JWT shape gate sees it as a
// candidate, but the payload segment is non-base64 garbage.
// Validator must surface this as 401, not 500.
const malformed = "eyJhbGciOiJIUzI1NiJ9.@@@notbase64@@@.signature";
const res = await postWithJwt(malformed);
expect(res.status).toBe(401);
});
});
// The auth layer resolves the JWT's env from the `sub` claim — NOT
// from the route path. So a JWT for env A hitting a route that
// fetches a resource from env B should never accidentally see env
// B's data. Test by minting a JWT for env A and asking for a
// resource that lives in env B — expect 404 (not 200).
describe("Cross-environment: JWT auth resolves env from sub, not URL", () => {
it("env A's JWT cannot read env B's resource: 404", async () => {
const server = getTestServer();
const a = await seedTestEnvironment(server.prisma);
const b = await seedTestEnvironment(server.prisma);
// Seed a real-ish run row in env B so the route would have
// something to find IF auth resolved the env from the URL.
const friendlyId = `run_${Math.random().toString(36).slice(2, 10)}`;
await server.prisma.taskRun.create({
data: {
friendlyId,
taskIdentifier: "test-task",
payload: "{}",
payloadType: "application/json",
traceId: `trace_${Math.random().toString(36).slice(2)}`,
spanId: `span_${Math.random().toString(36).slice(2)}`,
runtimeEnvironmentId: b.environment.id,
projectId: b.project.id,
organizationId: b.organization.id,
engine: "V2",
status: "COMPLETED_SUCCESSFULLY",
queue: "task/test-task",
},
});
const jwt = await generateJWT({
secretKey: a.apiKey,
payload: { pub: true, sub: a.environment.id, scopes: ["read:runs"] },
expirationTime: "15m",
});
const res = await server.webapp.fetch(`/api/v1/runs/${friendlyId}/result`, {
headers: { Authorization: `Bearer ${jwt}` },
});
// The route resolves runs scoped to the JWT's env (env A). The
// run lives in env B, so env A's view returns "not found" —
// critically, NOT 200.
expect(res.status).not.toBe(200);
expect([401, 404]).toContain(res.status);
});
});
});
@@ -0,0 +1,122 @@
// Comprehensive dashboard session-auth tests — see TRI-8742.
// Each test seeds a User + session cookie via seedTestUser / seedTestSession
// (helpers/seedTestSession.ts) and hits the shared webapp container.
import { describe, expect, it } from "vitest";
import { getTestServer } from "./helpers/sharedTestServer";
import { seedTestSession, seedTestUser } from "./helpers/seedTestSession";
describe("Dashboard", () => {
it("shared webapp container redirects /admin/concurrency to /login when unauthenticated", async () => {
const server = getTestServer();
const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" });
expect(res.status).toBe(302);
});
// Admin pages migrated to dashboardLoader({ authorization: { requireSuper: true } })
// in TRI-8717. The dashboardLoader resolves auth in three stages:
// 1. No session → redirect to /login?redirectTo=<path>.
// 2. Session, user.admin === false → redirect to / (no path leakage).
// 3. Session, user.admin === true → run the loader handler.
//
// Coverage strategy: pick three representative routes (the index, a
// tabbed sub-page, and the back-office tree) rather than all 14 —
// they all share the same dashboardLoader config so testing every
// file would just confirm the wrapper works, which the harness
// already proves. If the wrapper config drifts per-route in the
// future, add targeted tests for the divergent ones.
describe("Admin pages — requireSuper gate", () => {
const adminRoutes = [
"/admin",
"/admin/concurrency",
"/admin/back-office",
];
for (const path of adminRoutes) {
describe(`GET ${path}`, () => {
it("no session: redirects to /login?redirectTo=<path>", async () => {
const server = getTestServer();
const res = await server.webapp.fetch(path, { redirect: "manual" });
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/login");
// Path leaks deliberately so a successful login bounces the
// user back to where they were headed.
expect(location).toContain(`redirectTo=${encodeURIComponent(path)}`);
});
it("session for non-admin user: redirects to / (no path leakage)", async () => {
const server = getTestServer();
const user = await seedTestUser(server.prisma, { admin: false });
const cookie = await seedTestSession({ userId: user.id });
const res = await server.webapp.fetch(path, {
redirect: "manual",
headers: { Cookie: cookie },
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
// unauthorizedRedirect default in dashboardBuilder is "/".
// A non-admin landing on /admin shouldn't get redirectTo
// back to /admin once they upgrade — they're not getting in
// by re-auth.
expect(new URL(location, "http://localhost").pathname).toBe("/");
});
it("session for admin user: 2xx", async () => {
const server = getTestServer();
const user = await seedTestUser(server.prisma, { admin: true });
const cookie = await seedTestSession({ userId: user.id });
const res = await server.webapp.fetch(path, {
redirect: "manual",
headers: { Cookie: cookie },
});
// Loader handler ran — could be 200 (HTML) or 204 (Remix
// _data fetch). Either way, NOT a redirect.
expect(res.status).toBeLessThan(300);
});
});
}
});
// Action handlers behind requireSuper used to return 403 Unauthorized
// pre-RBAC — now they redirect to / via dashboardAction's
// unauthorizedRedirect. The ticket flagged this as a behaviour
// change worth locking in (any XHR fetcher that branched on 403
// would have regressed silently). Use admin.feature-flags POST as
// the canary — it's the simplest action of the bunch.
describe("Admin action — requireSuper gate (admin.feature-flags POST)", () => {
const path = "/admin/feature-flags";
it("no session: redirects to /login (POST)", async () => {
const server = getTestServer();
const res = await server.webapp.fetch(path, {
method: "POST",
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
redirect: "manual",
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/login");
});
it("session for non-admin user: redirects to / (was 403 pre-RBAC)", async () => {
const server = getTestServer();
const user = await seedTestUser(server.prisma, { admin: false });
const cookie = await seedTestSession({ userId: user.id });
const res = await server.webapp.fetch(path, {
method: "POST",
body: JSON.stringify({}),
headers: { "Content-Type": "application/json", Cookie: cookie },
redirect: "manual",
});
// Behaviour change from the TRI-8717 migration: the legacy
// path returned 403 Unauthorized; dashboardAction returns a
// 302 to "/" instead. Any client code branching on 403 needs
// updating — locking this in so a silent regression is loud.
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(new URL(location, "http://localhost").pathname).toBe("/");
});
});
});
-423
View File
@@ -1,423 +0,0 @@
import { describe, it, expect } from "vitest";
import { checkAuthorization, AuthorizationEntity } from "../app/services/authorization.server";
describe("checkAuthorization", () => {
// Test entities
const privateEntity: AuthorizationEntity = { type: "PRIVATE" };
const publicEntity: AuthorizationEntity = { type: "PUBLIC" };
const publicJwtEntityWithPermissions: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:runs:run_1234", "read:tasks", "read:tags:tag_5678"],
};
const publicJwtEntityNoPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
const publicJwtEntityWithTaskWritePermissions: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["write:tasks:task-1"],
};
describe("PRIVATE entity", () => {
it("should always return authorized regardless of action or resource", () => {
const result1 = checkAuthorization(privateEntity, "read", { runs: "run_1234" });
expect(result1.authorized).toBe(true);
expect(result1).not.toHaveProperty("reason");
const result2 = checkAuthorization(privateEntity, "read", { tasks: ["task_1", "task_2"] });
expect(result2.authorized).toBe(true);
expect(result2).not.toHaveProperty("reason");
const result3 = checkAuthorization(privateEntity, "read", { tags: "nonexistent_tag" });
expect(result3.authorized).toBe(true);
expect(result3).not.toHaveProperty("reason");
});
});
describe("PUBLIC entity", () => {
it("should always return unauthorized with reason regardless of action or resource", () => {
const result1 = checkAuthorization(publicEntity, "read", { runs: "run_1234" });
expect(result1.authorized).toBe(false);
if (!result1.authorized) {
expect(result1.reason).toBe("PUBLIC type is deprecated and has no access");
}
const result2 = checkAuthorization(publicEntity, "read", { tasks: ["task_1", "task_2"] });
expect(result2.authorized).toBe(false);
if (!result2.authorized) {
expect(result2.reason).toBe("PUBLIC type is deprecated and has no access");
}
const result3 = checkAuthorization(publicEntity, "read", { tags: "tag_5678" });
expect(result3.authorized).toBe(false);
if (!result3.authorized) {
expect(result3.reason).toBe("PUBLIC type is deprecated and has no access");
}
});
});
describe("PUBLIC_JWT entity with task write scope", () => {
it("should return authorized for specific resource scope", () => {
const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", {
tasks: "task-1",
});
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should return unauthorized with reason for unauthorized specific resources", () => {
const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", {
tasks: "task-2",
});
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token is missing required permissions. Token has the following permissions: 'write:tasks:task-1'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
});
describe("PUBLIC_JWT entity with scope", () => {
it("should return authorized for specific resource scope", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_1234",
});
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should return unauthorized with reason for unauthorized specific resources", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_5678",
});
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token is missing required permissions. Token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
it("should return authorized for general resource type scope", () => {
const result1 = checkAuthorization(publicJwtEntityWithPermissions, "read", {
tasks: "task_1234",
});
expect(result1.authorized).toBe(true);
expect(result1).not.toHaveProperty("reason");
const result2 = checkAuthorization(publicJwtEntityWithPermissions, "read", {
tasks: ["task_5678", "task_9012"],
});
expect(result2.authorized).toBe(true);
expect(result2).not.toHaveProperty("reason");
});
it("should return authorized if any resource in an array is authorized", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
tags: ["tag_1234", "tag_5678"],
});
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should return authorized for nonexistent resource types", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
// @ts-expect-error
nonexistent: "resource",
});
expect(result.authorized).toBe(false);
expect(result).toHaveProperty("reason");
});
});
describe("PUBLIC_JWT entity without scope", () => {
it("should always return unauthorized with reason regardless of action or resource", () => {
const result1 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
runs: "run_1234",
});
expect(result1.authorized).toBe(false);
if (!result1.authorized) {
expect(result1.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
const result2 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
tasks: ["task_1", "task_2"],
});
expect(result2.authorized).toBe(false);
if (!result2.authorized) {
expect(result2.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
const result3 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
tags: "tag_5678",
});
expect(result3.authorized).toBe(false);
if (!result3.authorized) {
expect(result3.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
});
describe("Edge cases", () => {
it("should handle empty resource objects", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {});
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe("Resource object is empty");
}
});
it("should handle undefined scope", () => {
const entityUndefinedPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
const result = checkAuthorization(entityUndefinedPermissions, "read", { runs: "run_1234" });
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
it("should handle empty scope array", () => {
const entityEmptyPermissions: AuthorizationEntity = { type: "PUBLIC_JWT", scopes: [] };
const result = checkAuthorization(entityEmptyPermissions, "read", { runs: "run_1234" });
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
it("should return authorized if any resource is authorized", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_1234", // This is authorized
tasks: "task_5678", // This is authorized (general permission)
tags: "tag_3456", // This is not authorized
});
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should return unauthorized only if no resources are authorized", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_5678", // Not authorized
tags: "tag_3456", // Not authorized
});
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toContain("Public Access Token is missing required permissions");
}
});
});
describe("Super scope", () => {
const entityWithSuperPermissions: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:all", "admin"],
};
const entityWithOneSuperPermission: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:all"],
};
it("should grant access with any of the super scope", () => {
const result1 = checkAuthorization(
entityWithSuperPermissions,
"read",
{ tasks: "task_1234" },
["read:all", "admin"]
);
expect(result1.authorized).toBe(true);
expect(result1).not.toHaveProperty("reason");
const result2 = checkAuthorization(
entityWithSuperPermissions,
"read",
{ tags: ["tag_1", "tag_2"] },
["write:all", "admin"]
);
expect(result2.authorized).toBe(true);
expect(result2).not.toHaveProperty("reason");
});
it("should grant access with one matching super permission", () => {
const result = checkAuthorization(
entityWithOneSuperPermission,
"read",
{ runs: "run_5678" },
["read:all", "admin"]
);
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should not grant access when no super scope match", () => {
const result = checkAuthorization(
entityWithOneSuperPermission,
"read",
{ tasks: "task_1234" },
["write:all", "admin"]
);
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token is missing required permissions. Token has the following permissions: 'read:all'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
it("should grant access to multiple resources with super scope", () => {
const result = checkAuthorization(
entityWithSuperPermissions,
"read",
{
tasks: "task_1234",
tags: ["tag_1", "tag_2"],
runs: "run_5678",
},
["read:all"]
);
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should fall back to specific scope when super scope are not provided", () => {
const entityWithSpecificPermissions: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:tasks", "read:tags"],
};
const result1 = checkAuthorization(entityWithSpecificPermissions, "read", {
tasks: "task_1234",
});
expect(result1.authorized).toBe(true);
expect(result1).not.toHaveProperty("reason");
const result2 = checkAuthorization(entityWithSpecificPermissions, "read", {
runs: "run_5678",
});
expect(result2.authorized).toBe(false);
if (!result2.authorized) {
expect(result2.reason).toBe(
"Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks', 'read:tags'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
});
describe("Query resource type", () => {
it("should grant access with read:query super scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query"],
};
const result = checkAuthorization(
entity,
"read",
{ query: "runs" },
["read:query", "read:all", "admin"]
);
expect(result.authorized).toBe(true);
});
it("should grant access with table-specific query scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query:runs"],
};
const result = checkAuthorization(entity, "read", { query: "runs" });
expect(result.authorized).toBe(true);
});
it("should deny access to different table with table-specific scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query:runs"],
};
const result = checkAuthorization(entity, "read", { query: "llm_metrics" });
expect(result.authorized).toBe(false);
});
it("should grant access with general read:query scope to any table", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query"],
};
const runsResult = checkAuthorization(entity, "read", { query: "runs" });
expect(runsResult.authorized).toBe(true);
const metricsResult = checkAuthorization(entity, "read", { query: "metrics" });
expect(metricsResult.authorized).toBe(true);
const llmResult = checkAuthorization(entity, "read", { query: "llm_metrics" });
expect(llmResult.authorized).toBe(true);
});
it("should grant access to multiple tables when querying with super scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query"],
};
const result = checkAuthorization(
entity,
"read",
{ query: ["runs", "llm_metrics"] },
["read:query", "read:all", "admin"]
);
expect(result.authorized).toBe(true);
});
it("should grant access to schema with read:query scope", () => {
const entity: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:query"],
};
const result = checkAuthorization(
entity,
"read",
{ query: "schema" },
["read:query", "read:all", "admin"]
);
expect(result.authorized).toBe(true);
});
});
describe("Without super scope", () => {
const entityWithoutSuperPermissions: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:tasks"],
};
it("should still grant access based on specific scope", () => {
const result = checkAuthorization(
entityWithoutSuperPermissions,
"read",
{ tasks: "task_1234" },
["read:all", "admin"]
);
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should deny access to resources not in scope", () => {
const result = checkAuthorization(
entityWithoutSuperPermissions,
"read",
{ runs: "run_5678" },
["read:all", "admin"]
);
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
});
});
@@ -0,0 +1,47 @@
// Inserts a `Session` row directly via Prisma so route auth tests can
// exercise routes that resolve a session by friendlyId or externalId.
//
// Note: not to be confused with `seedTestSession` in this directory —
// that helper builds a *dashboard cookie session* for cookie-auth tests.
// This helper builds an *agent-stream Session row* (the chat.agent
// runtime concept).
import type { PrismaClient, Session } from "@trigger.dev/database";
import { randomBytes } from "node:crypto";
function randomHex(len = 12): string {
return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
}
export async function seedTestApiSession(
prisma: PrismaClient,
env: {
id: string;
type: string;
organizationId: string;
projectId: string;
},
overrides?: { taskIdentifier?: string; externalId?: string | null }
): Promise<Session> {
const suffix = randomHex(8);
return prisma.session.create({
data: {
id: `session_${suffix}`,
friendlyId: `session_${suffix}`,
// `null` lets a caller exercise the externalId-absent code path
// (single-id auth resource); omit the override to get a unique
// externalId for the multi-key path.
externalId:
overrides?.externalId === null
? null
: overrides?.externalId ?? `ext_${suffix}`,
type: "chat.agent",
projectId: env.projectId,
runtimeEnvironmentId: env.id,
environmentType: env.type as Session["environmentType"],
organizationId: env.organizationId,
taskIdentifier: overrides?.taskIdentifier ?? `agent_${suffix}`,
triggerConfig: { basePayload: { messages: [], trigger: "preload" } },
},
});
}
+59
View File
@@ -0,0 +1,59 @@
import type { PrismaClient } from "@trigger.dev/database";
import { createCipheriv, createHash, randomBytes } from "node:crypto";
// Must match ENCRYPTION_KEY in internal-packages/testcontainers/src/webapp.ts
const ENCRYPTION_KEY = "test-encryption-key-for-e2e!!!!!";
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function encryptToken(value: string, key: string) {
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag: cipher.getAuthTag().toString("hex"),
};
}
function obfuscate(token: string): string {
return `${token.slice(0, 11)}${"•".repeat(20)}${token.slice(-4)}`;
}
export async function seedTestUser(prisma: PrismaClient, overrides?: { admin?: boolean }) {
const suffix = randomBytes(6).toString("hex");
return prisma.user.create({
data: {
email: `pat-user-${suffix}@test.local`,
authenticationMethod: "MAGIC_LINK",
admin: overrides?.admin ?? false,
},
});
}
// Seeds a PersonalAccessToken row using the same hashing/encryption scheme as
// webapp's services/personalAccessToken.server.ts so the webapp subprocess can
// authenticate against it.
export async function seedTestPAT(
prisma: PrismaClient,
userId: string,
opts: { revoked?: boolean } = {}
): Promise<{ token: string; id: string }> {
const token = `tr_pat_${randomBytes(20).toString("hex")}`;
const encrypted = encryptToken(token, ENCRYPTION_KEY);
const row = await prisma.personalAccessToken.create({
data: {
name: "e2e-test-pat",
userId,
encryptedToken: encrypted,
hashedToken: hashToken(token),
obfuscatedToken: obfuscate(token),
revokedAt: opts.revoked ? new Date() : null,
},
});
return { token, id: row.id };
}
+61
View File
@@ -0,0 +1,61 @@
import type { PrismaClient, TaskRun } from "@trigger.dev/database";
import { customAlphabet, nanoid } from "nanoid";
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
export interface SeededRun {
run: TaskRun;
runFriendlyId: string; // `run_...`
batchFriendlyId?: string; // `batch_...` when { withBatch: true }
}
// Minimum-viable TaskRun for auth-layer e2e tests — enough fields for
// ApiRetrieveRunPresenter.findRun to return it and for the authorization.resource
// callback to populate `runs`, `tags`, `batch`, `tasks` keys.
export async function seedTestRun(
prisma: PrismaClient,
opts: {
environmentId: string;
projectId: string;
runTags?: string[];
withBatch?: boolean;
}
): Promise<SeededRun> {
const runInternalId = idGenerator();
const runFriendlyId = `run_${runInternalId}`;
let batchInternalId: string | undefined;
if (opts.withBatch) {
batchInternalId = idGenerator();
await prisma.batchTaskRun.create({
data: {
id: batchInternalId,
friendlyId: `batch_${batchInternalId}`,
runtimeEnvironmentId: opts.environmentId,
},
});
}
const run = await prisma.taskRun.create({
data: {
id: runInternalId,
friendlyId: runFriendlyId,
taskIdentifier: "test-task",
payload: "{}",
payloadType: "application/json",
traceId: nanoid(32),
spanId: nanoid(16),
queue: "task/test-task",
runtimeEnvironmentId: opts.environmentId,
projectId: opts.projectId,
runTags: opts.runTags ?? [],
batchId: batchInternalId,
},
});
return {
run,
runFriendlyId,
batchFriendlyId: batchInternalId ? `batch_${batchInternalId}` : undefined,
};
}
@@ -0,0 +1,58 @@
// Produces a `Cookie:` header value for an authenticated session that the
// webapp under test will accept. Mirrors the webapp's
// `services/sessionStorage.server.ts` config exactly — the SESSION_SECRET
// must match what the webapp container was started with (see
// `internal-packages/testcontainers/src/webapp.ts` — currently
// "test-session-secret-for-e2e-tests").
//
// Used by dashboard auth tests (TRI-8742). Each test seeds its own user +
// session so test order doesn't matter.
import { createCookieSessionStorage } from "@remix-run/node";
import type { PrismaClient } from "@trigger.dev/database";
import { randomBytes } from "node:crypto";
// Must match SESSION_SECRET in internal-packages/testcontainers/src/webapp.ts.
const SESSION_SECRET = "test-session-secret-for-e2e-tests";
// Shape of the session config in apps/webapp/app/services/sessionStorage.server.ts.
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
sameSite: "lax",
path: "/",
httpOnly: true,
secrets: [SESSION_SECRET],
secure: false, // NODE_ENV is "test" in the spawned webapp.
maxAge: 60 * 60 * 24 * 365,
},
});
export async function seedTestUser(
prisma: PrismaClient,
overrides?: { admin?: boolean; email?: string }
) {
const suffix = randomBytes(6).toString("hex");
return prisma.user.create({
data: {
email: overrides?.email ?? `e2e-${suffix}@test.local`,
authenticationMethod: "MAGIC_LINK",
admin: overrides?.admin ?? false,
},
});
}
// Builds the `Cookie:` header value for a given user. Set this on test
// requests to the webapp to authenticate as that user.
//
// remix-auth's default sessionKey is "user" and stores AuthUser as
// { userId } — see apps/webapp/app/services/authUser.ts.
export async function seedTestSession(opts: { userId: string }): Promise<string> {
const session = await sessionStorage.getSession();
session.set("user", { userId: opts.userId });
const setCookie = await sessionStorage.commitSession(session);
// commitSession returns "__session=<value>; Path=/; ...". The Cookie
// header only needs the name=value pair.
const firstSegment = setCookie.split(";")[0];
return firstSegment;
}
@@ -0,0 +1,67 @@
import type { PrismaClient } from "@trigger.dev/database";
import { randomBytes } from "node:crypto";
import { seedTestPAT, seedTestUser } from "./seedTestPAT";
function randomHex(len = 12): string {
return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
}
// Composite test fixture: a User, an Organization with that user as a
// member, a Project owned by the org, a DEVELOPMENT environment, and a
// non-revoked PAT for the user.
//
// Used by the PAT-comprehensive matrix (TRI-8741) to exercise routes
// like GET /api/v1/projects/:projectRef/runs whose access check is
// `findProjectByRef(externalRef, userId)` — i.e. the project's org
// must have the userId in its members. seedTestEnvironment alone
// doesn't create the OrgMember link, which is why this helper exists.
//
// Caller passes `projectDeleted: true` to test the soft-deleted-
// project path; `userAdmin: true` to confirm the global admin flag
// doesn't add cross-org visibility (the route is per-user).
export async function seedTestUserProject(
prisma: PrismaClient,
opts: { userAdmin?: boolean; projectDeleted?: boolean } = {}
) {
const suffix = randomHex(8);
const apiKey = `tr_dev_${randomHex(24)}`;
const pkApiKey = `pk_dev_${randomHex(24)}`;
const user = await seedTestUser(prisma, { admin: opts.userAdmin ?? false });
const organization = await prisma.organization.create({
data: {
title: `e2e-pat-org-${suffix}`,
slug: `e2e-pat-org-${suffix}`,
v3Enabled: true,
members: { create: { userId: user.id, role: "ADMIN" } },
},
});
const project = await prisma.project.create({
data: {
name: `e2e-pat-project-${suffix}`,
slug: `e2e-pat-proj-${suffix}`,
externalRef: `proj_${suffix}`,
organizationId: organization.id,
engine: "V2",
deletedAt: opts.projectDeleted ? new Date() : null,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: "dev",
type: "DEVELOPMENT",
apiKey,
pkApiKey,
shortcode: suffix.slice(0, 4),
projectId: project.id,
organizationId: organization.id,
},
});
const pat = await seedTestPAT(prisma, user.id);
return { user, organization, project, environment, pat };
}

Some files were not shown because too many files have changed in this diff Show More