Skip to content

Commit 15b9aab

Browse files
committed
RBAC: invite flow role picker via OrgMemberInvite.rbacRoleId (TRI-8892)
Adds a Role `<Select>` to the invite form. Dropdown options are filtered by: 1. The inviter's own role — strictly below their level (Owner can pick any of the 4; Admin can pick Developer or Member; Developer/ Member don't see the picker because they can't invite anyway). 2. The org's plan tier — `rbac.getAssignableRoleIds(orgId)` already reflects this (Free/Hobby = Owner+Admin only, Pro+ unlocks Developer+Member). The picker is hidden entirely when `rbac.allRoles(orgId)` returns [] (OSS deployments with no plugin installed) — legacy invite path is unchanged for self-hosters. Schema: nullable `OrgMemberInvite.rbacRoleId text` column. On accept, if it's set, `acceptInvite` calls the plugin's `setUserRole` after the OrgMember insert (outside the Prisma transaction since the plugin uses a separate Drizzle / postgres-js connection — same compensating pattern as PAT-role assignment). If it's null, the runtime fallback derives a role from the legacy `OrgMember.role` write at first auth — no behaviour change. Server-side validation in the action layer rejects: - rbacRoleId not in `getAssignableRoleIds(orgId)` (plan-tier check). - rbacRoleId at or above the inviter's own level (the canInviteAtRole ladder). Legacy `OrgMemberInvite.role` enum (ADMIN/MEMBER) is still written based on the chosen RBAC role — Owner/Admin → "ADMIN", Developer/ Member → "MEMBER" — so OSS auth keeps working. Verified: - typecheck clean - 162/162 OSS e2e.full - 7/7 cloud enterprise e2e.full
1 parent f984824 commit 15b9aab

5 files changed

Lines changed: 225 additions & 10 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
RBAC: invite flow now lets the inviter pick the new member's role.
7+
The dropdown is filtered to roles the inviter is allowed to assign
8+
(strictly below their own level — Owner > Admin > Developer > Member)
9+
and gated by the org's plan tier (so Free/Hobby see Owner+Admin, Pro+
10+
unlocks Developer+Member). With no RBAC plugin installed the picker
11+
is hidden entirely and the legacy invite flow is unchanged.
12+
13+
Schema: new nullable `OrgMemberInvite.rbacRoleId text` column. Legacy
14+
`role` enum stays untouched and is set to ADMIN or MEMBER based on
15+
the chosen RBAC role for OSS-side auth compatibility. On accept, when
16+
`rbacRoleId` is non-null the plugin's `setUserRole` is called after
17+
the OrgMember insert; otherwise the runtime fallback derives the role
18+
from the legacy `role` field.

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

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +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";
56

67
const tokenValueLength = 40;
78
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
@@ -87,10 +88,23 @@ export async function inviteMembers({
8788
slug,
8889
emails,
8990
userId,
91+
rbacRoleId,
9092
}: {
9193
slug: string;
9294
emails: string[];
9395
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. Caller is responsible for verifying this role is
100+
* assignable by the inviter (level + plan tier) — the action layer
101+
* does that check before reaching here.
102+
*
103+
* Legacy `OrgMemberInvite.role` is still set for OSS compatibility.
104+
* Owner/Admin RBAC ids map to the legacy `ADMIN`; anything else maps
105+
* to legacy `MEMBER`.
106+
*/
107+
rbacRoleId?: string | null;
94108
}) {
95109
const org = await prisma.organization.findFirst({
96110
where: { slug, members: { some: { userId } } },
@@ -100,14 +114,24 @@ export async function inviteMembers({
100114
throw new Error("User does not have access to this organization");
101115
}
102116

117+
// The legacy enum is the source of truth for OSS auth — keep it in
118+
// sync with the chosen RBAC role so self-hosters who never install
119+
// the plugin still get sensible permissions.
120+
const legacyRole: "ADMIN" | "MEMBER" =
121+
rbacRoleId === SYSTEM_ROLE_IDS.owner ||
122+
rbacRoleId === SYSTEM_ROLE_IDS.admin
123+
? "ADMIN"
124+
: "MEMBER";
125+
103126
const invites = [...new Set(emails)].map(
104127
(email) =>
105128
({
106129
email,
107130
token: tokenGenerator(),
108131
organizationId: org.id,
109132
inviterId: userId,
110-
role: "MEMBER",
133+
role: legacyRole,
134+
rbacRoleId: rbacRoleId ?? null,
111135
} satisfies Prisma.OrgMemberInviteCreateManyInput)
112136
);
113137

@@ -212,15 +236,33 @@ export async function acceptInvite({
212236
remainingInvites,
213237
organization: invite.organization,
214238
inviteRole: invite.role,
239+
rbacRoleId: invite.rbacRoleId,
215240
};
216241
});
217242

218-
// No upfront RBAC UserRole insert — the loaded RBAC plugin (if any)
219-
// is responsible for deriving the new member's role from the legacy
220-
// OrgMember.role write inside the transaction above (ADMIN → Owner,
221-
// MEMBER → Admin) until an Owner explicitly changes their role on
222-
// the Teams page. Keeps the invite path tight and consistent with
223-
// the create-org path's behaviour.
243+
// If the invite carried an explicit RBAC role (the inviter picked one
244+
// when sending the invite), assign it now. Outside the Prisma
245+
// transaction because the RBAC plugin runs against a separate
246+
// postgres-js connection. Errors are logged, not fatal: the runtime
247+
// fallback derives a role from the legacy OrgMember.role write
248+
// above, so the user keeps working.
249+
//
250+
// No rbacRoleId → legacy behaviour, fallback covers it.
251+
if (result.rbacRoleId) {
252+
const roleResult = await rbac.setUserRole({
253+
userId: user.id,
254+
organizationId: result.organization.id,
255+
roleId: result.rbacRoleId,
256+
});
257+
if (!roleResult.ok) {
258+
logger.debug("acceptInvite: skipped RBAC role assignment", {
259+
organizationId: result.organization.id,
260+
userId: user.id,
261+
rbacRoleId: result.rbacRoleId,
262+
reason: roleResult.error,
263+
});
264+
}
265+
}
224266

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

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

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ import { Input } from "~/components/primitives/Input";
2525
import { InputGroup } from "~/components/primitives/InputGroup";
2626
import { Label } from "~/components/primitives/Label";
2727
import { Paragraph } from "~/components/primitives/Paragraph";
28+
import { Select, SelectItem } from "~/components/primitives/Select";
2829
import { $replica } from "~/db.server";
2930
import { env } from "~/env.server";
3031
import { useOrganization } from "~/hooks/useOrganizations";
3132
import { inviteMembers } from "~/models/member.server";
3233
import { redirectWithSuccessMessage } from "~/models/message.server";
3334
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
3435
import { scheduleEmail } from "~/services/email.server";
36+
import { rbac, SYSTEM_ROLE_IDS } from "~/services/rbac.server";
3537
import { requireUserId } from "~/services/session.server";
3638
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
3739
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
@@ -63,9 +65,52 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
6365
throw new Response("Not Found", { status: 404 });
6466
}
6567

66-
return typedjson(result);
68+
// Inviter's own role drives the "below their level" filter on the
69+
// dropdown. Plus assignable role IDs already encode the org's plan
70+
// tier — the intersection is what we offer.
71+
const [inviterRole, assignableRoleIds] = await Promise.all([
72+
rbac.getUserRole({ userId, organizationId: organization.id }),
73+
rbac.getAssignableRoleIds(organization.id),
74+
]);
75+
76+
return typedjson({ ...result, inviterRoleId: inviterRole?.id ?? null, assignableRoleIds });
77+
};
78+
79+
// Sentinel for "no RBAC role attached to invite" — the runtime
80+
// fallback will derive a role from the legacy OrgMember.role write at
81+
// accept time. Used when the org has no RBAC plugin installed (the
82+
// dropdown is hidden) or as a defensive default.
83+
const NO_RBAC_ROLE = "__no_rbac_role__";
84+
85+
// Owner > Admin > Developer > Member. An inviter can only assign a
86+
// role strictly below their own — Owners can pick any of the four,
87+
// Admins can pick Developer or Member, Developer/Member can't invite
88+
// at all. Custom roles are out of scope for this rule (TRI-8747's
89+
// 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,
6795
};
6896

97+
function canInviteAtRole(
98+
inviterRoleId: string | null,
99+
invitedRoleId: string
100+
): boolean {
101+
// No RBAC role on inviter (e.g. the runtime fallback couldn't derive
102+
// one) → fall back to the legacy OrgMember.role check the calling
103+
// code already enforces. Allow the invite to proceed; the action
104+
// would have already failed earlier if the inviter wasn't allowed
105+
// to invite at all.
106+
if (!inviterRoleId) return true;
107+
const inviter = ROLE_LEVEL[inviterRoleId];
108+
const invited = ROLE_LEVEL[invitedRoleId];
109+
// Custom roles aren't in the level table — refuse.
110+
if (inviter === undefined || invited === undefined) return false;
111+
return invited < inviter;
112+
}
113+
69114
const schema = z.object({
70115
emails: z.preprocess((i) => {
71116
if (typeof i === "string") return [i];
@@ -80,6 +125,7 @@ const schema = z.object({
80125

81126
return [""];
82127
}, z.string().email().array().nonempty("At least one email is required")),
128+
rbacRoleId: z.string().optional(),
83129
});
84130

85131
export const action: ActionFunction = async ({ request, params }) => {
@@ -94,11 +140,49 @@ export const action: ActionFunction = async ({ request, params }) => {
94140
return json(submission);
95141
}
96142

143+
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
144+
// role → don't pass one through; the runtime fallback handles it.
145+
// Validation: the chosen role must be in the org's assignable set
146+
// (which already enforces plan-tier gating + inviter's level).
147+
let resolvedRbacRoleId: string | null = null;
148+
const submittedRbacRoleId = submission.value.rbacRoleId;
149+
if (
150+
submittedRbacRoleId &&
151+
submittedRbacRoleId !== NO_RBAC_ROLE
152+
) {
153+
const org = await $replica.organization.findFirst({
154+
where: { slug: organizationSlug },
155+
select: { id: true },
156+
});
157+
if (!org) {
158+
return json({ errors: { body: "Organization not found" } }, { status: 404 });
159+
}
160+
const [inviterRole, assignableRoleIds] = await Promise.all([
161+
rbac.getUserRole({ userId, organizationId: org.id }),
162+
rbac.getAssignableRoleIds(org.id),
163+
]);
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+
);
176+
}
177+
resolvedRbacRoleId = submittedRbacRoleId;
178+
}
179+
97180
try {
98181
const invites = await inviteMembers({
99182
slug: organizationSlug,
100183
emails: submission.value.emails,
101184
userId,
185+
rbacRoleId: resolvedRbacRoleId,
102186
});
103187

104188
for (const invite of invites) {
@@ -128,12 +212,38 @@ export const action: ActionFunction = async ({ request, params }) => {
128212
};
129213

130214
export default function Page() {
131-
const { limits, canPurchaseSeats, seatPricing, extraSeats, maxSeatQuota, planSeatLimit } =
132-
useTypedLoaderData<typeof loader>();
215+
const {
216+
limits,
217+
canPurchaseSeats,
218+
seatPricing,
219+
extraSeats,
220+
maxSeatQuota,
221+
planSeatLimit,
222+
roles,
223+
inviterRoleId,
224+
assignableRoleIds,
225+
} = useTypedLoaderData<typeof loader>();
133226
const [total, setTotal] = useState(limits.used);
134227
const organization = useOrganization();
135228
const lastSubmission = useActionData();
136229

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+
);
238+
const showRolePicker = offerable.length > 0;
239+
240+
// Default to Member when offered (or the lowest-tier offered role).
241+
const defaultRoleId = showRolePicker
242+
? offerable.find((r) => r.id === SYSTEM_ROLE_IDS.member)?.id ??
243+
offerable[offerable.length - 1].id
244+
: NO_RBAC_ROLE;
245+
const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
246+
137247
const [form, { emails }] = useForm({
138248
id: "invite-members",
139249
// TODO: type this
@@ -232,6 +342,36 @@ export default function Page() {
232342
</Fragment>
233343
))}
234344
</InputGroup>
345+
{showRolePicker ? (
346+
<InputGroup>
347+
<Label htmlFor="rbacRoleId">Role</Label>
348+
<input type="hidden" name="rbacRoleId" value={selectedRoleId} />
349+
<Select<string, (typeof offerable)[number]>
350+
defaultValue={defaultRoleId}
351+
items={offerable}
352+
variant="tertiary/medium"
353+
dropdownIcon
354+
text={(v) =>
355+
offerable.find((r) => r.id === v)?.name ?? "Pick a role"
356+
}
357+
setValue={(next) => {
358+
if (typeof next === "string") setSelectedRoleId(next);
359+
}}
360+
>
361+
{(items) =>
362+
items.map((role) => (
363+
<SelectItem key={role.id} value={role.id}>
364+
{role.name}
365+
</SelectItem>
366+
))
367+
}
368+
</Select>
369+
<Paragraph variant="extra-small" className="text-text-dimmed">
370+
Invitees join with this role. They can be promoted later
371+
from the Team page.
372+
</Paragraph>
373+
</InputGroup>
374+
) : null}
235375
<FormButtons
236376
confirmButton={
237377
<Button type="submit" variant={"primary/small"} disabled={total > limits.limit}>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- TRI-8892: optional RBAC role assignment carried on the invite. When
2+
-- set, the accept-invite flow calls the loaded RBAC plugin's
3+
-- setUserRole(rbacRoleId) after the OrgMember insert; otherwise the
4+
-- runtime fallback derives the role from the legacy `role` column.
5+
ALTER TABLE "OrgMemberInvite" ADD COLUMN IF NOT EXISTS "rbacRoleId" TEXT;

internal-packages/database/prisma/schema.prisma

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,16 @@ model OrgMemberInvite {
265265
email String
266266
role OrgMemberRole @default(MEMBER)
267267
268+
/// Optional RBAC role to assign on invite acceptance. When set, the
269+
/// accept-invite flow calls the loaded RBAC plugin's setUserRole with
270+
/// this id after creating the OrgMember. Null = legacy behaviour, the
271+
/// runtime fallback derives the role from `role` above.
272+
///
273+
/// Plain text (not an FK) — the RBAC plugin's RbacRole table lives on
274+
/// a separate schema (Drizzle, not Prisma) so we can't model the FK
275+
/// here. Validation happens at write time (action) and read time
276+
/// (acceptInvite).
277+
rbacRoleId String?
268278
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
269279
organizationId String
270280

0 commit comments

Comments
 (0)