Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
95ee786
Initial commit of RBAC split setup
matt-aitken Apr 19, 2026
fa8cb51
Verdaccio publish
matt-aitken Apr 19, 2026
7b9e87d
Every change will republish when in dev
matt-aitken Apr 19, 2026
4376e3e
RBAC/plugin updates
matt-aitken Apr 21, 2026
1eaed1c
Update RBAC plugin interface: authenticateBearer/Session, drop Prisma…
matt-aitken Apr 22, 2026
fc9e1dc
JWT/realtime token integration: publicJWT subject, jwt metadata, allo…
matt-aitken Apr 22, 2026
3924869
Lazy loading of plugin
matt-aitken Apr 24, 2026
6ae0e4c
RBAC: force-fallback flag + env var + e2e fallback wiring (TRI-8715)
matt-aitken Apr 24, 2026
de1b738
RBAC: API auth e2e coverage — action + PAT + edge cases (TRI-8716)
matt-aitken Apr 24, 2026
21c6228
RBAC: resource-scoped JWT e2e coverage (TRI-8716 follow-up)
matt-aitken Apr 24, 2026
f3dc11d
RBAC: pre-migration JWT behaviour tests for TRI-8719 risks (TRI-8716)
matt-aitken Apr 24, 2026
a9820b0
RBAC plugin: array resources + action alias wrapper (TRI-8719 Phase A)
matt-aitken Apr 24, 2026
fbc7224
RBAC: migrate apiBuilder to rbac.authenticateBearer + ability.can (TR…
matt-aitken Apr 24, 2026
b112327
RBAC: dashboardLoader / dashboardAction + migrate admin pages (TRI-8717)
matt-aitken Apr 25, 2026
6903ec3
RBAC plugin: authenticateAuthorize* accepts array resources
matt-aitken Apr 26, 2026
0a28ad9
RBAC tests: shared-container test harness for the comprehensive auth …
matt-aitken Apr 26, 2026
ae73397
RBAC plugin: Result types on mutation methods + OSS fallback (TRI-8747)
matt-aitken Apr 26, 2026
d9840b6
RBAC: split dashboardBuilder so client-bundle imports resolve
matt-aitken Apr 27, 2026
7f37365
Code comments/formatting
matt-aitken Apr 27, 2026
dc3ef4b
Batch added resource
matt-aitken Apr 27, 2026
43677e6
Batch add resource
matt-aitken Apr 27, 2026
95c9893
RBAC: Teams page UI — role dropdowns, plan-aware disabling, manage ga…
matt-aitken Apr 27, 2026
07f41b6
Delete API batches
matt-aitken Apr 27, 2026
3106869
RBAC: auto-assign system roles on org create + invite accept (TRI-8854)
matt-aitken Apr 28, 2026
393aaef
RBAC: PAT creation flow with role selection (TRI-8749)
matt-aitken Apr 28, 2026
660e273
Use defaultValue instead of lots of useState
matt-aitken Apr 28, 2026
3b7f6ec
RBAC tests: PAT auth comprehensive matrix (TRI-8741)
matt-aitken Apr 28, 2026
76f5eca
RBAC tests: dashboard session auth for admin pages (TRI-8742)
matt-aitken Apr 28, 2026
4a4bf35
RBAC tests: cross-cutting auth edge cases (TRI-8743)
matt-aitken Apr 28, 2026
1b651a0
RBAC tests: waitpoint completions + input streams (TRI-8740)
matt-aitken Apr 28, 2026
5f09319
RBAC tests: trigger task routes (TRI-8733)
matt-aitken Apr 28, 2026
46e5499
RBAC tests: run lists (TRI-8736)
matt-aitken Apr 28, 2026
47d25c1
RBAC tests: run mutations — cancel + idempotencyKeys.reset (TRI-8735)
matt-aitken Apr 28, 2026
27f0533
RBAC tests: run resource routes — multi-key (TRI-8734)
matt-aitken Apr 28, 2026
e9e57f9
RBAC tests: batch retrieve + realtime (TRI-8737)
matt-aitken Apr 28, 2026
21f5827
RBAC tests: prompts (TRI-8738)
matt-aitken Apr 28, 2026
c3abfda
RBAC tests: deployments + query (TRI-8739)
matt-aitken Apr 28, 2026
7e7f76c
RBAC tests: unblock e2e.full harness; all 162 tests pass (TRI-8731)
matt-aitken Apr 28, 2026
38ab6b0
RBAC tests: parameterise RBAC_FORCE_FALLBACK in testcontainers (TRI-8…
matt-aitken Apr 28, 2026
fc64545
RBAC tests: extract projectCreated to break platform.v3.server cycle …
matt-aitken Apr 28, 2026
903fbea
Latest lockfile… although it'll probably get conflicted again
matt-aitken Apr 28, 2026
f23faec
RBAC: Roles page (TRI-8880)
matt-aitken Apr 28, 2026
09ea4d8
RBAC: drop upfront UserRole inserts from org-creation and invite flows
matt-aitken Apr 29, 2026
6c8f1e6
RBAC: scrub "enterprise" / "OSS" / cloud-side references from comments
matt-aitken Apr 29, 2026
4599909
RBAC: scrub enterprise reference from rbac-force-fallback server-chan…
matt-aitken Apr 29, 2026
5e86952
RBAC: drop Wildcards group from Roles page client-side mapping
matt-aitken Apr 29, 2026
a084376
RBAC: extend Permission + RbacResource for CASL conditional rules (TR…
matt-aitken Apr 29, 2026
4458d45
RBAC: rework Roles page as a permission × role comparison Table (TRI-…
matt-aitken Apr 30, 2026
e40fed8
RBAC: flex Roles page header + cell content horizontally with gap-1
matt-aitken Apr 30, 2026
fc995cf
RBAC: left-align Roles page role columns (header + cells)
matt-aitken Apr 30, 2026
436452d
RBAC: shrink-to-content sizing for non-Description columns on Roles page
matt-aitken Apr 30, 2026
09cb319
RBAC: revert column shrink-to-content sizing on Roles page
matt-aitken Apr 30, 2026
48d2ebc
RBAC: render conditional cells as plain dimmed text (no tick + badge)
matt-aitken Apr 30, 2026
dae1911
RBAC: invite flow role picker via OrgMemberInvite.rbacRoleId (TRI-8892)
matt-aitken Apr 30, 2026
668454e
Tightened up comments and log an error for failed role assignments
matt-aitken Apr 30, 2026
65796f3
RBAC: rbac.systemRoleIds() instead of duplicating role-id constants
matt-aitken May 1, 2026
c869ff4
RBAC: give upgrade-link rows a value so Ariakit handles the click
matt-aitken May 1, 2026
86ddcf1
RBAC: preserve render prop in SelectItem outside Combobox context
matt-aitken May 1, 2026
4b40b34
Fixed role link
matt-aitken May 1, 2026
59ee4c8
RBAC: replace systemRoleIds() with systemRoles() catalogue
matt-aitken May 1, 2026
977089c
RBAC: address PR review — batch trigger AND, fallback resilience, pic…
matt-aitken May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rbac-assignable-role-ids.md
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.
5 changes: 5 additions & 0 deletions .changeset/rbac-authenticate-authorize-arrays.md
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.
5 changes: 5 additions & 0 deletions .changeset/rbac-mutation-result-types.md
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.
5 changes: 5 additions & 0 deletions .changeset/rbac-plugin-array-resources.md
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).
5 changes: 5 additions & 0 deletions .changeset/rbac-system-role-ids-method.md
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.
5 changes: 5 additions & 0 deletions .changeset/rbac-system-roles.md
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).
116 changes: 116 additions & 0 deletions .github/workflows/e2e-webapp-auth-full.yml
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"
Comment thread
matt-aitken marked this conversation as resolved.

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"
6 changes: 6 additions & 0 deletions .server-changes/rbac-apibuilder-migration.md
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.
6 changes: 6 additions & 0 deletions .server-changes/rbac-dashboard-builder.md
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.
6 changes: 6 additions & 0 deletions .server-changes/rbac-force-fallback.md
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.
18 changes: 18 additions & 0 deletions .server-changes/rbac-invite-role-picker.md
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.
15 changes: 15 additions & 0 deletions .server-changes/rbac-pat-role-selection.md
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
Expand Up @@ -4,6 +4,7 @@ import {
Cog8ToothIcon,
CreditCardIcon,
LockClosedIcon,
ShieldCheckIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
Expand All @@ -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,
Expand Down Expand Up @@ -128,6 +130,14 @@ export function OrganizationSettingsSideMenu({
to={organizationTeamPath(organization)}
data-action="team"
/>
<SideMenuItem
name="Roles"
icon={ShieldCheckIcon}
activeIconColor="text-sky-500"
inactiveIconColor="text-sky-500"
to={organizationRolesPath(organization)}
data-action="roles"
/>
<SideMenuItem
name="Settings"
icon={Cog8ToothIcon}
Expand Down
10 changes: 9 additions & 1 deletion apps/webapp/app/components/primitives/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,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");
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,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)
Expand Down
60 changes: 57 additions & 3 deletions apps/webapp/app/models/member.server.ts
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);
Expand Down Expand Up @@ -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 } } },
Expand All @@ -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)
);

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t silently complete invite acceptance when RBAC role assignment fails.

At this point the org membership is already committed, so a real setUserRole() failure leaves the user in a partially provisioned state: they joined the org, but they may not have the role that migrated RBAC-gated routes expect. Please make non-fallback failures recoverable before returning success here (for example: compensating cleanup, retry/outbox, or surfacing a failure that prevents the invite from being consumed).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/models/member.server.ts` around lines 248 - 263, In
acceptInvite, don’t treat rbac.setUserRole failures as non-fatal: when
rbac.setUserRole returns !ok (the block checking result.rbacRoleId currently
only logs via logger.error), either perform compensating cleanup (remove the
just-created org membership / undo invite consumption using the membership or
invite service) or surface the failure to the caller by throwing an error so the
invite is not consumed; reference the existing acceptInvite flow and the
rbac.setUserRole call and ensure the invite consumption/membership creation is
rolled back or the error is propagated (alternatively implement a retry/outbox
for setUserRole) instead of silently continuing.


return { remainingInvites: result.remainingInvites, organization: result.organization };
}

export async function declineInvite({
Expand Down
6 changes: 6 additions & 0 deletions apps/webapp/app/models/organization.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ export async function createOrganization(
},
});

// No upfront RBAC UserRole insert — the loaded RBAC plugin (if any)
// is responsible for deriving the creator's role from the legacy
// OrgMember.role = "ADMIN" write above (which maps to Owner) until an
// Owner explicitly changes someone's role on the Teams page. Keeps
// the create-org path tight and lets the plugin stay the single
// source of truth for "who has what role" by default.
return { ...organization };
}

Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/app/models/project.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading