Skip to content

Commit e4981d1

Browse files
matt-aitkenclaude
andauthored
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>
1 parent 3cbe9f2 commit e4981d1

131 files changed

Lines changed: 8901 additions & 1713 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/plugin-auth-path.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/plugins": patch
3+
---
4+
5+
The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
name: "🛡️ E2E Tests: Webapp Auth (full)"
2+
3+
# Comprehensive RBAC auth test suite — see TRI-8731. Runs separately from
4+
# the smoke e2e-webapp.yml because it covers every route family with a
5+
# pass/fail matrix and would otherwise dominate per-PR CI time.
6+
#
7+
# Triggered:
8+
# - Manually via workflow_dispatch.
9+
# - Nightly via schedule.
10+
# - On pull requests touching auth-relevant files only (paths filter).
11+
12+
permissions:
13+
contents: read
14+
15+
on:
16+
workflow_dispatch:
17+
schedule:
18+
- cron: "0 4 * * *" # 04:00 UTC daily
19+
pull_request:
20+
paths:
21+
- "apps/webapp/app/services/routeBuilders/**"
22+
- "apps/webapp/app/services/rbac.server.ts"
23+
- "apps/webapp/app/services/apiAuth.server.ts"
24+
- "apps/webapp/app/services/personalAccessToken.server.ts"
25+
- "apps/webapp/app/services/sessionStorage.server.ts"
26+
- "apps/webapp/app/routes/api.v*.**"
27+
- "apps/webapp/app/routes/realtime.v*.**"
28+
- "apps/webapp/test/**/*.e2e.full.test.ts"
29+
- "apps/webapp/test/setup/global-e2e-full-setup.ts"
30+
- "apps/webapp/test/helpers/sharedTestServer.ts"
31+
- "apps/webapp/test/helpers/seedTestSession.ts"
32+
- "apps/webapp/vitest.e2e.full.config.ts"
33+
- "internal-packages/rbac/**"
34+
- "packages/plugins/**"
35+
- ".github/workflows/e2e-webapp-auth-full.yml"
36+
37+
jobs:
38+
e2eAuthFull:
39+
name: "🛡️ E2E Auth Tests (full)"
40+
runs-on: ubuntu-latest
41+
timeout-minutes: 30
42+
env:
43+
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
44+
steps:
45+
- name: 🔧 Disable IPv6
46+
run: |
47+
sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
48+
sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1
49+
sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1
50+
51+
- name: 🔧 Configure docker address pool
52+
run: |
53+
CONFIG='{
54+
"default-address-pools" : [
55+
{
56+
"base" : "172.17.0.0/12",
57+
"size" : 20
58+
},
59+
{
60+
"base" : "192.168.0.0/16",
61+
"size" : 24
62+
}
63+
]
64+
}'
65+
mkdir -p /etc/docker
66+
echo "$CONFIG" | sudo tee /etc/docker/daemon.json
67+
68+
- name: 🔧 Restart docker daemon
69+
run: sudo systemctl restart docker
70+
71+
- name: ⬇️ Checkout repo
72+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
73+
with:
74+
fetch-depth: 0
75+
# Don't leave the GITHUB_TOKEN in .git/config — this job
76+
# doesn't need to push and the persisted creds would be
77+
# readable from any subsequent step (zizmor/artipacked).
78+
persist-credentials: false
79+
80+
- name: ⎔ Setup pnpm
81+
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
82+
with:
83+
version: 10.33.2
84+
85+
- name: ⎔ Setup node
86+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
87+
with:
88+
node-version: 20.20.0
89+
cache: "pnpm"
90+
91+
- name: 🐳 Login to DockerHub
92+
if: ${{ env.DOCKERHUB_USERNAME }}
93+
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
94+
with:
95+
username: ${{ secrets.DOCKERHUB_USERNAME }}
96+
password: ${{ secrets.DOCKERHUB_TOKEN }}
97+
- name: 🐳 Skipping DockerHub login (no secrets available)
98+
if: ${{ !env.DOCKERHUB_USERNAME }}
99+
run: echo "DockerHub login skipped because secrets are not available."
100+
101+
- name: 🐳 Pre-pull testcontainer images
102+
if: ${{ env.DOCKERHUB_USERNAME }}
103+
run: |
104+
docker pull postgres:14
105+
docker pull redis:7.2
106+
docker pull testcontainers/ryuk:0.11.0
107+
108+
- name: 📥 Download deps
109+
run: pnpm install --frozen-lockfile
110+
111+
- name: 📀 Generate Prisma Client
112+
run: pnpm run generate
113+
114+
- name: 🏗️ Build Webapp
115+
run: pnpm run build --filter webapp
116+
117+
- name: 🛡️ Run Webapp Full Auth E2E Tests
118+
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.full.config.ts --reporter=default
119+
env:
120+
WEBAPP_TEST_VERBOSE: "1"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Webapp now supports a plugin system. Initially consolidates authentication and authorization paths.

apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Cog8ToothIcon,
55
CreditCardIcon,
66
LockClosedIcon,
7+
ShieldCheckIcon,
78
UserGroupIcon,
89
} from "@heroicons/react/20/solid";
910
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
@@ -14,6 +15,7 @@ import { useFeatures } from "~/hooks/useFeatures";
1415
import { type MatchedOrganization } from "~/hooks/useOrganizations";
1516
import { cn } from "~/utils/cn";
1617
import {
18+
organizationRolesPath,
1719
organizationSettingsPath,
1820
organizationSlackIntegrationPath,
1921
organizationTeamPath,
@@ -45,9 +47,11 @@ export type BuildInfo = {
4547
export function OrganizationSettingsSideMenu({
4648
organization,
4749
buildInfo,
50+
isUsingPlugin,
4851
}: {
4952
organization: MatchedOrganization;
5053
buildInfo: BuildInfo;
54+
isUsingPlugin: boolean;
5155
}) {
5256
const { isManagedCloud } = useFeatures();
5357
const featureFlags = useFeatureFlags();
@@ -128,6 +132,16 @@ export function OrganizationSettingsSideMenu({
128132
to={organizationTeamPath(organization)}
129133
data-action="team"
130134
/>
135+
{isUsingPlugin && (
136+
<SideMenuItem
137+
name="Roles"
138+
icon={ShieldCheckIcon}
139+
activeIconColor="text-sky-500"
140+
inactiveIconColor="text-sky-500"
141+
to={organizationRolesPath(organization)}
142+
data-action="roles"
143+
/>
144+
)}
131145
<SideMenuItem
132146
name="Settings"
133147
icon={Cog8ToothIcon}

apps/webapp/app/components/primitives/Select.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,15 @@ export function SelectItem({
463463
...props
464464
}: SelectItemProps) {
465465
const combobox = Ariakit.useComboboxContext();
466-
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
466+
// In a Combobox context we wrap the caller's render in ComboboxItem
467+
// so combobox keyboard nav still works. Outside a Combobox we pass
468+
// the render through verbatim — without this, callers like
469+
// SelectLinkItem (which uses render to swap in a <Link>) get their
470+
// render prop silently dropped, which is why those rows looked
471+
// clickable but didn't navigate.
472+
const render = combobox
473+
? <Ariakit.ComboboxItem render={props.render} />
474+
: props.render;
467475
const ref = React.useRef<HTMLDivElement>(null);
468476
const select = Ariakit.useSelectContext();
469477
const selectValue = select?.useState("value");

apps/webapp/app/env.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,6 +1542,9 @@ const EnvironmentSchema = z
15421542
// Private connections
15431543
PRIVATE_CONNECTIONS_ENABLED: z.string().optional(),
15441544
PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(),
1545+
1546+
// Force RBAC to not use the plugin
1547+
RBAC_FORCE_FALLBACK: BoolEnv.default(false),
15451548
})
15461549
.and(GithubAppEnvSchema)
15471550
.and(S2EnvSchema)

apps/webapp/app/models/member.server.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { type Prisma, prisma } from "~/db.server";
22
import { createEnvironment } from "./organization.server";
33
import { customAlphabet } from "nanoid";
4+
import { logger } from "~/services/logger.server";
5+
import { rbac } from "~/services/rbac.server";
46

57
const tokenValueLength = 40;
68
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
@@ -86,10 +88,19 @@ export async function inviteMembers({
8688
slug,
8789
emails,
8890
userId,
91+
rbacRoleId,
8992
}: {
9093
slug: string;
9194
emails: string[];
9295
userId: string;
96+
/**
97+
* Optional RBAC role to attach to the invite. When set, accepted
98+
* invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember
99+
* is created.
100+
*
101+
* `OrgMemberInvite.role` is still set if the plugin isn't installed.
102+
*/
103+
rbacRoleId?: string | null;
93104
}) {
94105
const org = await prisma.organization.findFirst({
95106
where: { slug, members: { some: { userId } } },
@@ -107,6 +118,7 @@ export async function inviteMembers({
107118
organizationId: org.id,
108119
inviterId: userId,
109120
role: "MEMBER",
121+
rbacRoleId: rbacRoleId ?? null,
110122
} satisfies Prisma.OrgMemberInviteCreateManyInput)
111123
);
112124

@@ -163,7 +175,7 @@ export async function acceptInvite({
163175
user: { id: string; email: string };
164176
inviteId: string;
165177
}) {
166-
return await prisma.$transaction(async (tx) => {
178+
const result = await prisma.$transaction(async (tx) => {
167179
// 1. Delete the invite and get the invite details
168180
const invite = await tx.orgMemberInvite.delete({
169181
where: {
@@ -207,8 +219,32 @@ export async function acceptInvite({
207219
},
208220
});
209221

210-
return { remainingInvites, organization: invite.organization };
222+
return {
223+
remainingInvites,
224+
organization: invite.organization,
225+
inviteRole: invite.role,
226+
rbacRoleId: invite.rbacRoleId,
227+
};
211228
});
229+
230+
// If the invite carried an explicit RBAC role. Errors are logged, not fatal.
231+
if (result.rbacRoleId) {
232+
const roleResult = await rbac.setUserRole({
233+
userId: user.id,
234+
organizationId: result.organization.id,
235+
roleId: result.rbacRoleId,
236+
});
237+
if (!roleResult.ok) {
238+
logger.error("acceptInvite: skipped RBAC role assignment", {
239+
organizationId: result.organization.id,
240+
userId: user.id,
241+
rbacRoleId: result.rbacRoleId,
242+
reason: roleResult.error,
243+
});
244+
}
245+
}
246+
247+
return { remainingInvites: result.remainingInvites, organization: result.organization };
212248
}
213249

214250
export async function declineInvite({

apps/webapp/app/models/project.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { $replica, prisma } from "~/db.server";
44
import type { Prisma, Project } from "@trigger.dev/database";
55
import { type Organization, createEnvironment } from "./organization.server";
66
import { env } from "~/env.server";
7-
import { projectCreated } from "~/services/platform.v3.server";
7+
import { projectCreated } from "~/services/projectCreated.server";
88
export type { Project } from "@trigger.dev/database";
99

1010
const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20);

0 commit comments

Comments
 (0)