Skip to content

Commit f5dabbe

Browse files
committed
RBAC: replace systemRoleIds() with systemRoles() catalogue
The OSS no longer needs to know individual role names. systemRoles(orgId) returns a plugin-owned, ordered SystemRole[] (id, name, description, available) — the cloud plugin owns the canonical order, the descriptions, and the per-org plan-tier 'available' flag. Hidden roles (Member in v1) are filtered out entirely. OSS callers iterate the array and use array index for the level ladder; no role-name strings except for the legacy OrgMember.role enum mapping shim, which is now isolated to one filter in member.server.ts.
1 parent c5327d2 commit f5dabbe

8 files changed

Lines changed: 117 additions & 85 deletions

File tree

.changeset/rbac-system-roles.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+
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).

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

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,23 @@ export async function inviteMembers({
110110
throw new Error("User does not have access to this organization");
111111
}
112112

113-
// 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();
118-
const legacyRole: "ADMIN" | "MEMBER" =
119-
ids && (rbacRoleId === ids.owner || rbacRoleId === ids.admin)
120-
? "ADMIN"
121-
: "MEMBER";
113+
// The legacy OrgMember.role enum (ADMIN | MEMBER) needs a write so
114+
// pre-RBAC code paths still see a sensible role on the invite. Map by
115+
// role NAME — "Owner" and "Admin" become "ADMIN", everything else
116+
// becomes "MEMBER". This is the one place left where the OSS keys off
117+
// role names; the plugin owns the systemRoles catalogue and we just
118+
// match on the well-known legacy-equivalent labels here.
119+
// null means no plugin installed → default to "MEMBER" (legacy two-
120+
// option flow).
121+
const sys = await rbac.systemRoles(org.id);
122+
const adminEquivalent = new Set(
123+
(sys ?? [])
124+
.filter((r) => r.name === "Owner" || r.name === "Admin")
125+
.map((r) => r.id)
126+
);
127+
const legacyRole: "ADMIN" | "MEMBER" = adminEquivalent.has(rbacRoleId ?? "")
128+
? "ADMIN"
129+
: "MEMBER";
122130

123131
const invites = [...new Set(emails)].map(
124132
(email) =>

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

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -68,23 +68,23 @@ 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, systemRoleIds] = await Promise.all([
71+
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
7272
rbac.getUserRole({ userId, organizationId: organization.id }),
7373
rbac.getAssignableRoleIds(organization.id),
74-
rbac.systemRoleIds(),
74+
rbac.systemRoles(organization.id),
7575
]);
7676

7777
// Build the dropdown's offerable set server-side: roles that are
7878
// (a) assignable on the current plan AND (b) strictly below the
7979
// 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.
80+
// need to know about the system-role catalogue or the ladder.
8181
const assignableSet = new Set(assignableRoleIds);
82-
const offerableRoleIds = systemRoleIds
82+
const offerableRoleIds = systemRoles
8383
? result.roles
8484
.filter(
8585
(r) =>
8686
assignableSet.has(r.id) &&
87-
isStrictlyBelow(systemRoleIds, inviterRole?.id ?? null, r.id)
87+
isStrictlyBelow(systemRoles, inviterRole?.id ?? null, r.id)
8888
)
8989
.map((r) => r.id)
9090
: [];
@@ -98,27 +98,27 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
9898
// dropdown is hidden) or as a defensive default.
9999
const NO_RBAC_ROLE = "__no_rbac_role__";
100100

101-
// Owner > Admin > Developer > Member. An inviter can only assign a
102-
// role strictly below their own — Owners can pick any of the four,
103-
// Admins can pick Developer or Member, Developer/Member can't invite
104-
// at all. Custom roles are out of scope for this rule (TRI-8747's
105-
// follow-up will handle them).
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-
};
101+
// An inviter can only assign a role strictly below their own. The
102+
// plugin's systemRoles array is in canonical order (highest authority
103+
// first), so array index drives the ladder — earlier index = higher
104+
// rank. Plan-tier filtering happens separately via assignableRoleIds;
105+
// the ladder is the absolute hierarchy. Custom roles aren't in the
106+
// table and are refused (TRI-8747's follow-up will handle them).
107+
type LadderRole = { id: string };
108+
109+
function buildRoleLevel(roles: ReadonlyArray<LadderRole>): Record<string, number> {
110+
const level: Record<string, number> = {};
111+
roles.forEach((r, i) => {
112+
// Top of the array = highest level. Subtract from length so larger
113+
// numbers always mean "more authority" — no off-by-one when a role
114+
// is added or removed.
115+
level[r.id] = roles.length - i;
116+
});
117+
return level;
118118
}
119119

120120
function isStrictlyBelow(
121-
ids: { owner: string; admin: string; developer: string; member: string },
121+
roles: ReadonlyArray<LadderRole>,
122122
inviterRoleId: string | null,
123123
invitedRoleId: string
124124
): boolean {
@@ -128,7 +128,7 @@ function isStrictlyBelow(
128128
// would have already failed earlier if the inviter wasn't allowed
129129
// to invite at all.
130130
if (!inviterRoleId) return true;
131-
const level = buildRoleLevel(ids);
131+
const level = buildRoleLevel(roles);
132132
const inviter = level[inviterRoleId];
133133
const invited = level[invitedRoleId];
134134
// Custom roles aren't in the level table — refuse.
@@ -182,12 +182,12 @@ export const action: ActionFunction = async ({ request, params }) => {
182182
if (!org) {
183183
return json({ errors: { body: "Organization not found" } }, { status: 404 });
184184
}
185-
const [inviterRole, assignableRoleIds, systemRoleIds] = await Promise.all([
185+
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
186186
rbac.getUserRole({ userId, organizationId: org.id }),
187187
rbac.getAssignableRoleIds(org.id),
188-
rbac.systemRoleIds(),
188+
rbac.systemRoles(org.id),
189189
]);
190-
if (!systemRoleIds) {
190+
if (!systemRoles) {
191191
// No plugin installed but the form somehow submitted a role id —
192192
// ignore it (fall through to legacy behaviour rather than 400).
193193
resolvedRbacRoleId = null;
@@ -201,7 +201,7 @@ export const action: ActionFunction = async ({ request, params }) => {
201201
}
202202
if (
203203
!isStrictlyBelow(
204-
systemRoleIds,
204+
systemRoles,
205205
inviterRole?.id ?? null,
206206
submittedRbacRoleId
207207
)

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

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,18 @@ export const loader = dashboardLoader(
6262
throw new Response("Not Found", { status: 404 });
6363
}
6464

65-
const [roles, assignableRoleIds, allPermissions, systemRoleIds] = await Promise.all([
65+
const [roles, assignableRoleIds, allPermissions, systemRoles] = await Promise.all([
6666
rbac.allRoles(orgId),
6767
rbac.getAssignableRoleIds(orgId),
6868
rbac.allPermissions(orgId),
69-
rbac.systemRoleIds(),
69+
rbac.systemRoles(orgId),
7070
]);
7171

7272
return typedjson({
7373
roles,
7474
assignableRoleIds,
7575
allPermissions,
76-
systemRoleIds,
76+
systemRoles,
7777
});
7878
}
7979
);
@@ -131,7 +131,7 @@ const GROUP_ORDER = [
131131
] as const;
132132

133133
export default function Page() {
134-
const { roles, assignableRoleIds, allPermissions, systemRoleIds } =
134+
const { roles, assignableRoleIds, allPermissions, systemRoles } =
135135
useTypedLoaderData<typeof loader>();
136136
const organization = useOrganization();
137137
const plan = useCurrentPlan();
@@ -145,18 +145,14 @@ export default function Page() {
145145
const rolesById = new Map<string, LoaderRole>(roles.map((r) => [r.id, r]));
146146
const assignable = new Set(assignableRoleIds);
147147

148-
// Column ordering: Owner / Admin / Developer / Member, then any
149-
// custom roles in the order rbac.allRoles returned them. systemRoleIds
150-
// is null when no plugin is installed — there are no system roles to
151-
// pin; fall through to whatever order rbac.allRoles returns.
152-
const systemRoleOrder: ReadonlyArray<{ id: string; name: string }> = systemRoleIds
153-
? [
154-
{ id: systemRoleIds.owner, name: "Owner" },
155-
{ id: systemRoleIds.admin, name: "Admin" },
156-
{ id: systemRoleIds.developer, name: "Developer" },
157-
{ id: systemRoleIds.member, name: "Member" },
158-
]
159-
: [];
148+
// Column ordering follows the plugin's canonical systemRoles order
149+
// (highest authority first), then any custom roles in the order
150+
// rbac.allRoles returned them. systemRoles is null when no plugin is
151+
// installed; fall through to whatever order rbac.allRoles returns.
152+
// Each entry's `available` flag reflects plan-tier eligibility — we
153+
// render unavailable system roles too, but PlanBadge tags them so
154+
// customers see the comparison and know what an upgrade unlocks.
155+
const systemRoleOrder = systemRoles ?? [];
160156
const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id));
161157
const systemColumns = systemRoleOrder.flatMap((meta) => {
162158
const role = rolesById.get(meta.id);
@@ -199,7 +195,7 @@ export default function Page() {
199195
<PlanBadge
200196
roleId={role.id}
201197
assignable={assignable}
202-
systemRoleIds={systemRoleIds}
198+
systemRoleIdSet={systemRoleIdSet}
203199
/>
204200
</div>
205201
</TableHeaderCell>
@@ -271,19 +267,21 @@ function EmptyState() {
271267
function PlanBadge({
272268
roleId,
273269
assignable,
274-
systemRoleIds,
270+
systemRoleIdSet,
275271
}: {
276272
roleId: string;
277273
assignable: ReadonlySet<string>;
278-
systemRoleIds: { developer: string; member: string } | null;
274+
systemRoleIdSet: ReadonlySet<string>;
279275
}) {
280276
// Roles the org's plan doesn't permit get a small upgrade-tier hint
281277
// in the column header. The cell rendering is identical regardless
282278
// — the comparison value is still useful even on Free/Hobby.
283279
if (assignable.has(roleId)) return null;
284-
// System role gating: Owner+Admin always available; Member/Developer
285-
// only on Pro+; custom roles only on Enterprise.
286-
if (systemRoleIds && (roleId === systemRoleIds.member || roleId === systemRoleIds.developer)) {
280+
// System roles render as "Pro" (the gating tier where they unlock —
281+
// Free/Hobby see Owner+Admin only, Pro adds the rest). Custom roles
282+
// render as "Enterprise" — only Enterprise plans can create or assign
283+
// them.
284+
if (systemRoleIdSet.has(roleId)) {
287285
return <Badge variant="extra-small">Pro</Badge>;
288286
}
289287
return <Badge variant="extra-small">Enterprise</Badge>;

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

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ async function loadSystemRolesForUser(userId: string) {
7171
select: { organizationId: true },
7272
orderBy: { createdAt: "asc" },
7373
});
74-
if (!orgMember) return { roles: [], userRoleId: null as string | null };
74+
if (!orgMember) {
75+
return { roles: [], userRoleId: null as string | null, orgId: null as string | null };
76+
}
7577

7678
const allRoles = await rbac.allRoles(orgMember.organizationId);
7779
const systemRoles = allRoles.filter((r) => r.isSystem);
@@ -81,26 +83,32 @@ async function loadSystemRolesForUser(userId: string) {
8183
organizationId: orgMember.organizationId,
8284
});
8385

84-
return { roles: systemRoles, userRoleId: userRole?.id ?? null };
86+
return {
87+
roles: systemRoles,
88+
userRoleId: userRole?.id ?? null,
89+
orgId: orgMember.organizationId,
90+
};
8591
}
8692

8793
export const loader = async ({ request }: LoaderFunctionArgs) => {
8894
const userId = await requireUserId(request);
8995

9096
try {
91-
const [personalAccessTokens, { roles, userRoleId }] = await Promise.all([
97+
const [personalAccessTokens, { roles, userRoleId, orgId }] = await Promise.all([
9298
getValidPersonalAccessTokens(userId),
9399
loadSystemRolesForUser(userId),
94100
]);
95101

96102
// Default the role picker to the user's own role in their primary
97103
// org so a freshly-created PAT isn't more privileged than the
98-
// person creating it. Falls back to Member if they don't have one
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 ?? "";
104+
// person creating it. Falls back to the most-restrictive role
105+
// available on the org's plan if they don't have one. When the
106+
// user isn't a member of any org or no RBAC plugin is installed,
107+
// the picker is hidden anyway, so defaultRoleId is just a
108+
// placeholder.
109+
const sys = orgId ? await rbac.systemRoles(orgId) : null;
110+
const lowestAvailable = (sys ?? []).filter((r) => r.available).at(-1)?.id ?? "";
111+
const defaultRoleId = userRoleId ?? lowestAvailable;
104112

105113
return typedjson({
106114
personalAccessTokens,

internal-packages/rbac/src/fallback.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
157157
return auth;
158158
}
159159

160-
async systemRoleIds() {
160+
async systemRoles(_organizationId: string) {
161161
// No plugin installed → no seeded roles. Callers handle null by
162162
// hiding role-picker UI / skipping role assignment writes.
163163
return null;

internal-packages/rbac/src/index.ts

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

141-
async systemRoleIds() {
142-
return (await this.c()).systemRoleIds();
141+
async systemRoles(...args: Parameters<RoleBaseAccessController["systemRoles"]>) {
142+
return (await this.c()).systemRoles(...args);
143143
}
144144

145145
async allPermissions(

packages/plugins/src/rbac.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
/**
2-
* Stable IDs for the four built-in system roles. Values are tied to
3-
* the plugin's seed migration; the same map is returned by both the
4-
* default fallback and any installed plugin so callers can rely on
5-
* the IDs without knowing which implementation is loaded.
2+
* Plugin-owned metadata for a built-in system role. The plugin returns
3+
* these in canonical order (highest authority first) so the dashboard
4+
* can render columns / build a level ladder without knowing role names.
5+
*
6+
* Roles the plugin doesn't expose at all (e.g. seeded but with the
7+
* `is_hidden` flag set in the cloud plugin) are not returned by
8+
* `systemRoles()` — there's no "advertised but absent" state.
9+
*
10+
* `available` indicates whether the role is assignable on the *org's
11+
* plan*. v1: Free/Hobby plans get Owner+Admin available; Pro+ adds
12+
* Developer. Consumers may render unavailable rows with an upgrade
13+
* badge, hide them, or otherwise gate UI on the flag.
614
*/
7-
export type SystemRoleIds = {
8-
owner: string;
9-
admin: string;
10-
developer: string;
11-
member: string;
15+
export type SystemRole = {
16+
id: string;
17+
name: string;
18+
description: string;
19+
available: boolean;
1220
};
1321

1422
export type Permission = {
@@ -119,11 +127,16 @@ export interface RoleBaseAccessController {
119127
check: { action: string; resource: RbacResource | RbacResource[] }
120128
): Promise<SessionAuthResult>;
121129

122-
// Stable IDs for the four built-in system roles. Returns null when
123-
// no plugin is installed — there are no seeded roles to refer to in
124-
// that case (the default fallback's `allRoles` returns []). Plugins
125-
// return the constants tied to their seed migration.
126-
systemRoleIds(): Promise<SystemRoleIds | null>;
130+
// Plugin-owned catalogue of built-in system roles for the given org,
131+
// in canonical order (highest authority first). Returns null when no
132+
// plugin is installed — there are no seeded roles to refer to in that
133+
// case (the default fallback's `allRoles` returns []).
134+
//
135+
// Hidden roles (e.g. Member in v1) are filtered out entirely. Each
136+
// entry's `available` flag reflects whether the org's plan permits
137+
// assigning that role; consumers can render unavailable entries with
138+
// an upgrade badge or hide them.
139+
systemRoles(organizationId: string): Promise<SystemRole[] | null>;
127140

128141
// Role introspection. The fallback returns []; a plugin may return
129142
// its own role catalogue.

0 commit comments

Comments
 (0)