Skip to content

Commit 0e430c0

Browse files
committed
RBAC: rbac.systemRoleIds() instead of duplicating role-id constants
Adds a new `systemRoleIds(): Promise<SystemRoleIds | null>` method on the `RoleBaseAccessController` interface. Returns `{ owner, admin, developer, member }` from any installed plugin and `null` from the default fallback (matches the `allRoles → []` semantics — there are no seeded roles to refer to in OSS). Drops the `SYSTEM_ROLE_IDS` constant from `~/services/rbac.server` so consumers can't reach for hardcoded role-id strings. Updates the four sites that used it: - `models/member.server.ts` (invite flow's legacy-role mapping) - `routes/account.tokens` (PAT default) - `routes/_app.orgs.$organizationSlug.settings.roles` (Roles page comparison grid column ordering + plan-tier badges) - `routes/_app.orgs.$organizationSlug.invite` (role picker) The Roles page and invite route both pass the IDs through their loaders rather than referencing them at module top level — which was the root cause of the "Invite a team member button hard-refreshes the dashboard" bug: importing a `.server.ts` symbol from client-rendered code left a dangling client-bundle reference. Verified: typecheck clean, 162/162 OSS e2e.full, 7/7 cloud enterprise e2e.full.
1 parent f73fb79 commit 0e430c0

9 files changed

Lines changed: 156 additions & 84 deletions

File tree

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+
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.

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

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

77
const tokenValueLength = 40;
88
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
@@ -111,8 +111,12 @@ export async function inviteMembers({
111111
}
112112

113113
// The legacy enum is the source of truth without the plugin installed.
114+
// Owner/Admin RBAC ids → "ADMIN"; everything else → "MEMBER". Pull
115+
// the canonical IDs off the plugin so we don't duplicate them here;
116+
// null means no plugin → default to "MEMBER" (legacy two-option flow).
117+
const ids = await rbac.systemRoleIds();
114118
const legacyRole: "ADMIN" | "MEMBER" =
115-
rbacRoleId === SYSTEM_ROLE_IDS.owner || rbacRoleId === SYSTEM_ROLE_IDS.admin
119+
ids && (rbacRoleId === ids.owner || rbacRoleId === ids.admin)
116120
? "ADMIN"
117121
: "MEMBER";
118122

apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx

Lines changed: 75 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { inviteMembers } from "~/models/member.server";
3333
import { redirectWithSuccessMessage } from "~/models/message.server";
3434
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
3535
import { scheduleEmail } from "~/services/email.server";
36-
import { rbac, SYSTEM_ROLE_IDS } from "~/services/rbac.server";
36+
import { rbac } from "~/services/rbac.server";
3737
import { requireUserId } from "~/services/session.server";
3838
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
3939
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
@@ -68,12 +68,28 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
6868
// Inviter's own role drives the "below their level" filter on the
6969
// dropdown. Plus assignable role IDs already encode the org's plan
7070
// tier — the intersection is what we offer.
71-
const [inviterRole, assignableRoleIds] = await Promise.all([
71+
const [inviterRole, assignableRoleIds, systemRoleIds] = await Promise.all([
7272
rbac.getUserRole({ userId, organizationId: organization.id }),
7373
rbac.getAssignableRoleIds(organization.id),
74+
rbac.systemRoleIds(),
7475
]);
7576

76-
return typedjson({ ...result, inviterRoleId: inviterRole?.id ?? null, assignableRoleIds });
77+
// Build the dropdown's offerable set server-side: roles that are
78+
// (a) assignable on the current plan AND (b) strictly below the
79+
// inviter's own level. The client just renders these — it doesn't
80+
// need to know about the system-role ID constants or the ladder.
81+
const assignableSet = new Set(assignableRoleIds);
82+
const offerableRoleIds = systemRoleIds
83+
? result.roles
84+
.filter(
85+
(r) =>
86+
assignableSet.has(r.id) &&
87+
isStrictlyBelow(systemRoleIds, inviterRole?.id ?? null, r.id)
88+
)
89+
.map((r) => r.id)
90+
: [];
91+
92+
return typedjson({ ...result, offerableRoleIds });
7793
};
7894

7995
// Sentinel for "no RBAC role attached to invite" — the runtime
@@ -87,14 +103,22 @@ const NO_RBAC_ROLE = "__no_rbac_role__";
87103
// Admins can pick Developer or Member, Developer/Member can't invite
88104
// at all. Custom roles are out of scope for this rule (TRI-8747's
89105
// follow-up will handle them).
90-
const ROLE_LEVEL: Record<string, number> = {
91-
[SYSTEM_ROLE_IDS.owner]: 4,
92-
[SYSTEM_ROLE_IDS.admin]: 3,
93-
[SYSTEM_ROLE_IDS.developer]: 2,
94-
[SYSTEM_ROLE_IDS.member]: 1,
95-
};
106+
function buildRoleLevel(ids: {
107+
owner: string;
108+
admin: string;
109+
developer: string;
110+
member: string;
111+
}): Record<string, number> {
112+
return {
113+
[ids.owner]: 4,
114+
[ids.admin]: 3,
115+
[ids.developer]: 2,
116+
[ids.member]: 1,
117+
};
118+
}
96119

97-
function canInviteAtRole(
120+
function isStrictlyBelow(
121+
ids: { owner: string; admin: string; developer: string; member: string },
98122
inviterRoleId: string | null,
99123
invitedRoleId: string
100124
): boolean {
@@ -104,8 +128,9 @@ function canInviteAtRole(
104128
// would have already failed earlier if the inviter wasn't allowed
105129
// to invite at all.
106130
if (!inviterRoleId) return true;
107-
const inviter = ROLE_LEVEL[inviterRoleId];
108-
const invited = ROLE_LEVEL[invitedRoleId];
131+
const level = buildRoleLevel(ids);
132+
const inviter = level[inviterRoleId];
133+
const invited = level[invitedRoleId];
109134
// Custom roles aren't in the level table — refuse.
110135
if (inviter === undefined || invited === undefined) return false;
111136
return invited < inviter;
@@ -143,7 +168,7 @@ export const action: ActionFunction = async ({ request, params }) => {
143168
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
144169
// role → don't pass one through; the runtime fallback handles it.
145170
// Validation: the chosen role must be in the org's assignable set
146-
// (which already enforces plan-tier gating + inviter's level).
171+
// (plan-tier) and strictly below the inviter's own level.
147172
let resolvedRbacRoleId: string | null = null;
148173
const submittedRbacRoleId = submission.value.rbacRoleId;
149174
if (
@@ -157,24 +182,37 @@ export const action: ActionFunction = async ({ request, params }) => {
157182
if (!org) {
158183
return json({ errors: { body: "Organization not found" } }, { status: 404 });
159184
}
160-
const [inviterRole, assignableRoleIds] = await Promise.all([
185+
const [inviterRole, assignableRoleIds, systemRoleIds] = await Promise.all([
161186
rbac.getUserRole({ userId, organizationId: org.id }),
162187
rbac.getAssignableRoleIds(org.id),
188+
rbac.systemRoleIds(),
163189
]);
164-
const assignable = new Set(assignableRoleIds);
165-
if (!assignable.has(submittedRbacRoleId)) {
166-
return json(
167-
{ errors: { body: "You can't invite someone with this role on your current plan" } },
168-
{ status: 400 }
169-
);
170-
}
171-
if (!canInviteAtRole(inviterRole?.id ?? null, submittedRbacRoleId)) {
172-
return json(
173-
{ errors: { body: "You can only invite members at or below your own role" } },
174-
{ status: 403 }
175-
);
190+
if (!systemRoleIds) {
191+
// No plugin installed but the form somehow submitted a role id —
192+
// ignore it (fall through to legacy behaviour rather than 400).
193+
resolvedRbacRoleId = null;
194+
} else {
195+
const assignable = new Set(assignableRoleIds);
196+
if (!assignable.has(submittedRbacRoleId)) {
197+
return json(
198+
{ errors: { body: "You can't invite someone with this role on your current plan" } },
199+
{ status: 400 }
200+
);
201+
}
202+
if (
203+
!isStrictlyBelow(
204+
systemRoleIds,
205+
inviterRole?.id ?? null,
206+
submittedRbacRoleId
207+
)
208+
) {
209+
return json(
210+
{ errors: { body: "You can only invite members at or below your own role" } },
211+
{ status: 403 }
212+
);
213+
}
214+
resolvedRbacRoleId = submittedRbacRoleId;
176215
}
177-
resolvedRbacRoleId = submittedRbacRoleId;
178216
}
179217

180218
try {
@@ -220,27 +258,24 @@ export default function Page() {
220258
maxSeatQuota,
221259
planSeatLimit,
222260
roles,
223-
inviterRoleId,
224-
assignableRoleIds,
261+
offerableRoleIds,
225262
} = useTypedLoaderData<typeof loader>();
226263
const [total, setTotal] = useState(limits.used);
227264
const organization = useOrganization();
228265
const lastSubmission = useActionData();
229266

230-
// Filter the role catalogue down to what THIS inviter can actually
231-
// assign — intersection of (assignable on this plan) and (strictly
232-
// below inviter's level). With no plugin installed, roles is [] and
233-
// we hide the whole picker.
234-
const assignable = new Set(assignableRoleIds);
235-
const offerable = roles.filter(
236-
(r) => assignable.has(r.id) && canInviteAtRole(inviterRoleId, r.id)
237-
);
267+
// The loader filtered the catalogue to roles this inviter can
268+
// actually assign (plan tier × strict-below-my-level). With no plugin
269+
// installed, offerableRoleIds is [] and the picker hides entirely.
270+
const offerableSet = new Set(offerableRoleIds);
271+
const offerable = roles.filter((r) => offerableSet.has(r.id));
238272
const showRolePicker = offerable.length > 0;
239273

240-
// Default to Member when offered (or the lowest-tier offered role).
274+
// Default to the lowest-tier offered role (the loader returns roles
275+
// in its allRoles order, which the plugin emits Owner→Member; the
276+
// last entry is the most restrictive).
241277
const defaultRoleId = showRolePicker
242-
? offerable.find((r) => r.id === SYSTEM_ROLE_IDS.member)?.id ??
243-
offerable[offerable.length - 1].id
278+
? offerable[offerable.length - 1].id
244279
: NO_RBAC_ROLE;
245280
const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
246281

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
import { cn } from "~/utils/cn";
3232
import { $replica } from "~/db.server";
3333
import { useOrganization } from "~/hooks/useOrganizations";
34-
import { rbac, SYSTEM_ROLE_IDS } from "~/services/rbac.server";
34+
import { rbac } from "~/services/rbac.server";
3535
import {
3636
dashboardLoader,
3737
} from "~/services/routeBuilders/dashboardBuilder";
@@ -72,13 +72,20 @@ export const loader = dashboardLoader(
7272
throw new Response("Not Found", { status: 404 });
7373
}
7474

75-
const [roles, assignableRoleIds, allPermissions] = await Promise.all([
76-
rbac.allRoles(orgId),
77-
rbac.getAssignableRoleIds(orgId),
78-
rbac.allPermissions(orgId),
79-
]);
75+
const [roles, assignableRoleIds, allPermissions, systemRoleIds] =
76+
await Promise.all([
77+
rbac.allRoles(orgId),
78+
rbac.getAssignableRoleIds(orgId),
79+
rbac.allPermissions(orgId),
80+
rbac.systemRoleIds(),
81+
]);
8082

81-
return typedjson({ roles, assignableRoleIds, allPermissions });
83+
return typedjson({
84+
roles,
85+
assignableRoleIds,
86+
allPermissions,
87+
systemRoleIds,
88+
});
8289
}
8390
);
8491

@@ -87,19 +94,6 @@ type LoaderRole = LoaderData["roles"][number];
8794
type LoaderPermission = LoaderData["allPermissions"][number];
8895
type RolePermission = LoaderRole["permissions"][number];
8996

90-
// Display order for the system roles. Custom roles render afterwards
91-
// in whatever order rbac.allRoles returns them.
92-
const SYSTEM_ROLE_ORDER: ReadonlyArray<{ id: string; name: string }> = [
93-
{ id: SYSTEM_ROLE_IDS.owner, name: "Owner" },
94-
{ id: SYSTEM_ROLE_IDS.admin, name: "Admin" },
95-
{ id: SYSTEM_ROLE_IDS.developer, name: "Developer" },
96-
{ id: SYSTEM_ROLE_IDS.member, name: "Member" },
97-
];
98-
99-
const SYSTEM_ROLE_ID_SET: ReadonlySet<string> = new Set(
100-
SYSTEM_ROLE_ORDER.map((r) => r.id)
101-
);
102-
10397
// Permission name → display group. The wire-format Permission only
10498
// carries `name` and `description`, so this lives client-side.
10599
const PERMISSION_GROUP_BY_NAME: Record<string, string> = {
@@ -148,7 +142,7 @@ const GROUP_ORDER = [
148142
] as const;
149143

150144
export default function Page() {
151-
const { roles, assignableRoleIds, allPermissions } =
145+
const { roles, assignableRoleIds, allPermissions, systemRoleIds } =
152146
useTypedLoaderData<typeof loader>();
153147
const organization = useOrganization();
154148
const plan = useCurrentPlan();
@@ -163,13 +157,25 @@ export default function Page() {
163157
const assignable = new Set(assignableRoleIds);
164158

165159
// Column ordering: Owner / Admin / Developer / Member, then any
166-
// custom roles in the order rbac.allRoles returned them.
167-
const systemColumns = SYSTEM_ROLE_ORDER.flatMap((meta) => {
160+
// custom roles in the order rbac.allRoles returned them. systemRoleIds
161+
// is null when no plugin is installed — there are no system roles to
162+
// pin; fall through to whatever order rbac.allRoles returns.
163+
const systemRoleOrder: ReadonlyArray<{ id: string; name: string }> =
164+
systemRoleIds
165+
? [
166+
{ id: systemRoleIds.owner, name: "Owner" },
167+
{ id: systemRoleIds.admin, name: "Admin" },
168+
{ id: systemRoleIds.developer, name: "Developer" },
169+
{ id: systemRoleIds.member, name: "Member" },
170+
]
171+
: [];
172+
const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id));
173+
const systemColumns = systemRoleOrder.flatMap((meta) => {
168174
const role = rolesById.get(meta.id);
169175
return role ? [{ role, fallbackName: meta.name }] : [];
170176
});
171177
const customColumns = roles
172-
.filter((r) => !SYSTEM_ROLE_ID_SET.has(r.id))
178+
.filter((r) => !systemRoleIdSet.has(r.id))
173179
.map((role) => ({ role, fallbackName: role.name }));
174180
const columns = [...systemColumns, ...customColumns];
175181

@@ -212,6 +218,7 @@ export default function Page() {
212218
<PlanBadge
213219
roleId={role.id}
214220
assignable={assignable}
221+
systemRoleIds={systemRoleIds}
215222
/>
216223
</div>
217224
</TableHeaderCell>
@@ -286,9 +293,11 @@ function EmptyState() {
286293
function PlanBadge({
287294
roleId,
288295
assignable,
296+
systemRoleIds,
289297
}: {
290298
roleId: string;
291299
assignable: ReadonlySet<string>;
300+
systemRoleIds: { developer: string; member: string } | null;
292301
}) {
293302
// Roles the org's plan doesn't permit get a small upgrade-tier hint
294303
// in the column header. The cell rendering is identical regardless
@@ -297,8 +306,8 @@ function PlanBadge({
297306
// System role gating: Owner+Admin always available; Member/Developer
298307
// only on Pro+; custom roles only on Enterprise.
299308
if (
300-
roleId === SYSTEM_ROLE_IDS.member ||
301-
roleId === SYSTEM_ROLE_IDS.developer
309+
systemRoleIds &&
310+
(roleId === systemRoleIds.member || roleId === systemRoleIds.developer)
302311
) {
303312
return <Badge variant="extra-small">Pro</Badge>;
304313
}

apps/webapp/app/routes/account.tokens/route.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
import { SimpleTooltip } from "~/components/primitives/Tooltip";
3838
import { redirectWithSuccessMessage } from "~/models/message.server";
3939
import { prisma } from "~/db.server";
40-
import { rbac, SYSTEM_ROLE_IDS } from "~/services/rbac.server";
40+
import { rbac } from "~/services/rbac.server";
4141
import {
4242
type CreatedPersonalAccessToken,
4343
type ObfuscatedPersonalAccessToken,
@@ -96,9 +96,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
9696
// Default the role picker to the user's own role in their primary
9797
// org so a freshly-created PAT isn't more privileged than the
9898
// person creating it. Falls back to Member if they don't have one
99-
// (new user, or no RBAC plugin installed so no role assignments
100-
// exist).
101-
const defaultRoleId = userRoleId ?? SYSTEM_ROLE_IDS.member;
99+
// (new user). When no RBAC plugin is installed, systemRoleIds()
100+
// returns null and the picker is hidden anyway, so defaultRoleId
101+
// is just a placeholder in that branch.
102+
const ids = await rbac.systemRoleIds();
103+
const defaultRoleId = userRoleId ?? ids?.member ?? "";
102104

103105
return typedjson({
104106
personalAccessTokens,

apps/webapp/app/services/rbac.server.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,3 @@ export const rbac = plugin.create(
1616
{ getSessionUserId },
1717
{ forceFallback: env.RBAC_FORCE_FALLBACK }
1818
);
19-
20-
// Stable IDs for the four built-in system roles. They never change —
21-
// anything that needs a default role at creation time keys off these.
22-
// The default fallback's setUserRole returns
23-
// `{ ok: false, error: "RBAC plugin not installed" }` and is safe to
24-
// call with these ids; it just no-ops.
25-
export const SYSTEM_ROLE_IDS = {
26-
owner: "sys_role_owner",
27-
admin: "sys_role_admin",
28-
developer: "sys_role_developer",
29-
member: "sys_role_member",
30-
} as const;

internal-packages/rbac/src/fallback.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
157157
return auth;
158158
}
159159

160+
async systemRoleIds() {
161+
// No plugin installed → no seeded roles. Callers handle null by
162+
// hiding role-picker UI / skipping role assignment writes.
163+
return null;
164+
}
165+
160166
async allPermissions(): Promise<Permission[]> {
161167
return [];
162168
}

internal-packages/rbac/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ class LazyController implements RoleBaseAccessController {
138138
return auth;
139139
}
140140

141+
async systemRoleIds() {
142+
return (await this.c()).systemRoleIds();
143+
}
144+
141145
async allPermissions(
142146
...args: Parameters<RoleBaseAccessController["allPermissions"]>
143147
): Promise<Permission[]> {

0 commit comments

Comments
 (0)