-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(webapp): consolidate auth path + add comprehensive auth tests #3499
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
95ee786
fa8cb51
7b9e87d
4376e3e
1eaed1c
fc9e1dc
3924869
6ae0e4c
de1b738
21c6228
f3dc11d
a9820b0
fbc7224
b112327
6903ec3
0a28ad9
ae73397
d9840b6
7f37365
dc3ef4b
43677e6
95c9893
07f41b6
3106869
393aaef
660e273
3b7f6ec
76f5eca
4a4bf35
1b651a0
5f09319
46e5499
47d25c1
27f0533
e9e57f9
21f5827
c3abfda
7e7f76c
38ab6b0
fc64545
903fbea
f23faec
09ea4d8
6c8f1e6
4599909
5e86952
a084376
4458d45
e40fed8
fc995cf
436452d
09cb319
48d2ebc
dae1911
668454e
65796f3
c869ff4
86ddcf1
4b40b34
59ee4c8
977089c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@trigger.dev/plugins": patch | ||
| --- | ||
|
|
||
| RBAC plugin: new `getAssignableRoleIds(organizationId)` method on `RoleBaseAccessController`. Returns the subset of `allRoles(organizationId)` IDs that may be assigned right now — used by the Teams page UI to disable role-dropdown options that aren't currently assignable. The default fallback returns `[]` (permissive — `allRoles` already returns `[]` so there's nothing to gate); a plugin may apply its own gating policy and return the assignable subset. Server-side enforcement (rejecting an actual `setUserRole` to a non-assignable role) is unchanged and remains the source of truth — this method is purely a UI affordance. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@trigger.dev/plugins": minor | ||
| --- | ||
|
|
||
| RBAC plugin: `RoleBaseAccessController.authenticateAuthorizeBearer` and `authenticateAuthorizeSession` now accept `RbacResource | RbacResource[]` for `check.resource`, matching `RbacAbility.can`. This was an inconsistency — abilities accepted arrays but the convenience methods didn't, so callers wanting the array form had to call `authenticateBearer` + `ability.can` manually. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@trigger.dev/plugins": patch | ||
| --- | ||
|
|
||
| RBAC plugin: mutation methods on `RoleBaseAccessController` now return discriminated `Result` types instead of throwing on expected error paths. `createRole` and `updateRole` return `RoleMutationResult` (`{ ok: true; role: Role } | { ok: false; error: string }`); `deleteRole`, `setUserRole`, `removeUserRole`, `setTokenRole`, and `removeTokenRole` return `RoleAssignmentResult` (`{ ok: true } | { ok: false; error: string }`). The webapp surfaces the `error` strings directly to users (system role edits, plan-tier gating, validation conflicts) so a thrown exception now signals only an unexpected failure (DB outage, bug). Read methods (`getUserRole`, `getTokenRole`, `allRoles`, `allPermissions`) are unchanged. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@trigger.dev/plugins": minor | ||
| --- | ||
|
|
||
| RBAC plugin: `RbacAbility.can(action, resource)` now accepts either a single `RbacResource` or `RbacResource[]`. Array form means "grant access if any resource in the array passes", matching the multi-key authorisation semantics expected by routes that touch multiple resources (e.g. a run that also carries a batch id, tags, and a task identifier — a JWT scoped to any of them grants access). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@trigger.dev/plugins": patch | ||
| --- | ||
|
|
||
| RBAC plugin: new `systemRoleIds(): Promise<SystemRoleIds | null>` method on `RoleBaseAccessController`. Returns `{ owner, admin, developer, member }` with the seed-migration role IDs when a plugin is loaded; returns `null` when no plugin is installed (matches the `allRoles → []` semantics — there are no seeded roles to refer to). Lets consumers (invite flow, PAT defaults, Roles page comparison grid) get the canonical IDs without duplicating constants in the consuming app. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@trigger.dev/plugins": patch | ||
| --- | ||
|
|
||
| RBAC plugin: replaced `systemRoleIds()` with `systemRoles()`. The new method returns an ordered `SystemRole[]` (highest authority first) where each entry carries `id`, `name`, `description`, and `available`. The OSS no longer needs to know individual role names — it just iterates the canonical order from the plugin. `available: false` lets a plugin advertise a role without exposing it (used by v1 to ship Owner/Admin/Developer while keeping Member's prod-restriction promise unmade until the env-tier route wiring lands). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| 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@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: ⎔ Setup pnpm | ||
| uses: pnpm/action-setup@v4 | ||
| with: | ||
| version: 10.23.0 | ||
|
|
||
| - name: ⎔ Setup node | ||
| uses: buildjet/setup-node@v4 | ||
| with: | ||
| node-version: 20.20.0 | ||
| cache: "pnpm" | ||
|
|
||
| - name: 🐳 Login to DockerHub | ||
| if: ${{ env.DOCKERHUB_USERNAME }} | ||
| uses: docker/login-action@v3 | ||
| 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" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: improvement | ||
| --- | ||
|
|
||
| Migrate `apiBuilder.server.ts` to `rbac.authenticateBearer` + `ability.can` (TRI-8719). All 30 API routes that used `authorization.superScopes` now rely on the RBAC plugin's scope-algebra plus an `ACTION_ALIASES` compatibility map (`trigger`/`batchTrigger`/`update` satisfied by `write:*` scopes). Two intentional improvements: empty-resource routes (`/api/v1/batches`, `/api/v1/idempotencyKeys/:key/reset`) now accept JWTs (previously denied by the legacy empty-resource short-circuit); id-scoped `write:tasks:*` scopes now grant `POST /api/v1/tasks/:taskId/trigger` (previously denied by literal superScope mismatch). Both are strict supersets of the current accept set — no JWT regresses. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: improvement | ||
| --- | ||
|
|
||
| Add `dashboardLoader` / `dashboardAction` route builders that route session auth through the RBAC plugin (`rbac.authenticateSession` + `ability.canSuper()` / `ability.can`) and migrate the platform admin pages onto them. Routes that only need a logged-in user with no authorisation continue to use `requireUser` / `requireUserId` — the builder is opt-in for routes with explicit auth checks. Migrated routes: `admin.tsx`, `admin._index.tsx`, `admin.concurrency.tsx`, `admin.feature-flags.tsx`, `admin.notifications.tsx`, `admin.orgs.tsx`, `admin.data-stores.tsx`, `admin.back-office.tsx` (+ children), `admin.llm-models.*` (5 routes). Behavioural change: actions that previously threw `403 Unauthorized` on non-admins now redirect to `/` along with the loader — uniform with the builder's behaviour. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: improvement | ||
| --- | ||
|
|
||
| RBAC plugin: add RBAC_FORCE_FALLBACK env var so tests can pin the loader to the default fallback without depending on whether a plugin happens to be installed in node_modules. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
|
|
||
| RBAC: invite flow now lets the inviter pick the new member's role. | ||
| The dropdown is filtered to roles the inviter is allowed to assign | ||
| (strictly below their own level — Owner > Admin > Developer > Member) | ||
| and gated by the org's plan tier (so Free/Hobby see Owner+Admin, Pro+ | ||
| unlocks Developer+Member). With no RBAC plugin installed the picker | ||
| is hidden entirely and the legacy invite flow is unchanged. | ||
|
|
||
| Schema: new nullable `OrgMemberInvite.rbacRoleId text` column. Legacy | ||
| `role` enum stays untouched and is set to ADMIN or MEMBER based on | ||
| the chosen RBAC role for OSS-side auth compatibility. On accept, when | ||
| `rbacRoleId` is non-null the plugin's `setUserRole` is called after | ||
| the OrgMember insert; otherwise the runtime fallback derives the role | ||
| from the legacy `role` field. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
|
|
||
| RBAC: PAT creation flow now lets users pick a system role at create | ||
| time, persisted via the RBAC plugin's `setTokenRole`. Defaults to the | ||
| caller's own role so a PAT can't be more privileged than the person | ||
| creating it. Custom (org-defined) roles are out of scope for v1 — only | ||
| the four global system roles are offered, and the binding is global to | ||
| the PAT regardless of which org the request later targets. A | ||
| compensating-delete on `setTokenRole` failure keeps the PAT row and | ||
| the role row consistent without cross-store transaction wrestling. | ||
| With no RBAC plugin installed the dropdown is hidden, no roleId is | ||
| submitted, and the PAT works exactly as before. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } } }, | ||
|
|
@@ -99,14 +110,33 @@ export async function inviteMembers({ | |
| throw new Error("User does not have access to this organization"); | ||
| } | ||
|
|
||
| // The legacy OrgMember.role enum (ADMIN | MEMBER) needs a write so | ||
| // pre-RBAC code paths still see a sensible role on the invite. Map by | ||
| // role NAME — "Owner" and "Admin" become "ADMIN", everything else | ||
| // becomes "MEMBER". This is the one place left where the OSS keys off | ||
| // role names; the plugin owns the systemRoles catalogue and we just | ||
| // match on the well-known legacy-equivalent labels here. | ||
| // null means no plugin installed → default to "MEMBER" (legacy two- | ||
| // option flow). | ||
| const sys = await rbac.systemRoles(org.id); | ||
| const adminEquivalent = new Set( | ||
| (sys ?? []) | ||
| .filter((r) => r.name === "Owner" || r.name === "Admin") | ||
| .map((r) => r.id) | ||
| ); | ||
| const legacyRole: "ADMIN" | "MEMBER" = adminEquivalent.has(rbacRoleId ?? "") | ||
| ? "ADMIN" | ||
| : "MEMBER"; | ||
|
|
||
| const invites = [...new Set(emails)].map( | ||
| (email) => | ||
| ({ | ||
| email, | ||
| token: tokenGenerator(), | ||
| organizationId: org.id, | ||
| inviterId: userId, | ||
| role: "MEMBER", | ||
| role: legacyRole, | ||
| rbacRoleId: rbacRoleId ?? null, | ||
| } satisfies Prisma.OrgMemberInviteCreateManyInput) | ||
| ); | ||
|
|
||
|
|
@@ -163,7 +193,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 +237,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, | ||
| }); | ||
| } | ||
| } | ||
|
Comment on lines
+248
to
+263
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t silently complete invite acceptance when RBAC role assignment fails. At this point the org membership is already committed, so a real 🤖 Prompt for AI Agents |
||
|
|
||
| return { remainingInvites: result.remainingInvites, organization: result.organization }; | ||
| } | ||
|
|
||
| export async function declineInvite({ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.