Skip to content

Commit ac55499

Browse files
committed
RBAC: auto-assign system roles on org create + invite accept (TRI-8854)
Pairs with the enterprise/db backfill migration (cloud side) so every new (user, org) pair gets a UserRole row from day one without anyone falling through to PERMISSIVE_ABILITY on the Teams page. Mapping mirrors the backfill (legacy ADMIN had full access; the new Admin role excludes billing + member management, so legacy ADMIN belongs in the new Owner slot, not the new Admin slot): legacy ADMIN -> Owner (sys_role_owner) legacy MEMBER -> Member (sys_role_member) Changes: - services/rbac.server.ts: export SYSTEM_ROLE_IDS constant. The IDs are seeded by the enterprise/db migration and never change; both org creation and invite acceptance import from here so the role reference is in one place. - models/organization.server.ts: createOrganization calls rbac.setUserRole({ roleId: owner }) after the org row is created. Outside any transaction (rbac uses a separate Drizzle/postgres-js connection). On OSS the fallback returns ok=false; we log + continue since the legacy OrgMember.role write is the source of truth there. - models/member.server.ts: acceptInvite assigns Owner if the invite was ADMIN (defensive — the current UI only invites with MEMBER) or Member otherwise. setUserRole runs after the prisma transaction commits for the same reason as above. Returns the same shape as before so callers don't change. Verification: typecheck clean. Migration step (TRI-8854 part 1) is on the cloud side; together they ensure both existing and new (user, org) pairs land on a sensible RBAC role.
1 parent 8bd9f81 commit ac55499

4 files changed

Lines changed: 83 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
RBAC: auto-assign system roles when creating an org or accepting an
7+
invite (TRI-8854). createOrganization assigns the Owner role to the
8+
creator; acceptInvite assigns Owner if the invite was ADMIN (defensive
9+
— current UI only invites with MEMBER) or Member otherwise. Pairs with
10+
the enterprise/db migration that backfills UserRole rows from existing
11+
OrgMember.role data on RBAC go-live.

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

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

57
const tokenValueLength = 40;
68
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
@@ -163,7 +165,7 @@ export async function acceptInvite({
163165
user: { id: string; email: string };
164166
inviteId: string;
165167
}) {
166-
return await prisma.$transaction(async (tx) => {
168+
const result = await prisma.$transaction(async (tx) => {
167169
// 1. Delete the invite and get the invite details
168170
const invite = await tx.orgMemberInvite.delete({
169171
where: {
@@ -207,8 +209,41 @@ export async function acceptInvite({
207209
},
208210
});
209211

210-
return { remainingInvites, organization: invite.organization };
212+
return {
213+
remainingInvites,
214+
organization: invite.organization,
215+
inviteRole: invite.role,
216+
};
217+
});
218+
219+
// 5. Assign the corresponding RBAC role for the new member. Done
220+
// outside the transaction because rbac runs against a separate
221+
// postgres-js connection (Drizzle, not Prisma) — calling it inside
222+
// the tx would mix transaction boundaries. The legacy OrgMember.role
223+
// → RBAC mapping matches the backfill migration (TRI-8854):
224+
// ADMIN → Owner
225+
// MEMBER → Member
226+
// In practice every invite is created with role=MEMBER (see
227+
// inviteMembers above — there's no UI to invite someone as ADMIN),
228+
// so the ADMIN branch is defensive cover for direct DB writes.
229+
// OSS fallback returns ok=false; we log + continue (legacy
230+
// OrgMember.role is the source of truth for OSS auth).
231+
const roleId =
232+
result.inviteRole === "ADMIN" ? SYSTEM_ROLE_IDS.owner : SYSTEM_ROLE_IDS.member;
233+
const roleResult = await rbac.setUserRole({
234+
userId: user.id,
235+
organizationId: result.organization.id,
236+
roleId,
211237
});
238+
if (!roleResult.ok) {
239+
logger.debug("acceptInvite: skipped RBAC role assignment", {
240+
organizationId: result.organization.id,
241+
userId: user.id,
242+
reason: roleResult.error,
243+
});
244+
}
245+
246+
return { remainingInvites: result.remainingInvites, organization: result.organization };
212247
}
213248

214249
export async function declineInvite({

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import slug from "slug";
1212
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
1313
import { env } from "~/env.server";
1414
import { featuresForUrl } from "~/features.server";
15+
import { logger } from "~/services/logger.server";
16+
import { rbac, SYSTEM_ROLE_IDS } from "~/services/rbac.server";
1517
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
1618
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
1719
export type { Organization };
@@ -82,6 +84,26 @@ export async function createOrganization(
8284
},
8385
});
8486

87+
// Assign the creator the Owner system role so the new Teams page UI
88+
// shows them as an Owner from the moment the org exists. Mirrors the
89+
// legacy `OrgMember.role = "ADMIN"` write above (TRI-8854: legacy
90+
// ADMIN maps to new Owner, not new Admin — the new Admin role
91+
// excludes billing + member management). On the OSS deployment the
92+
// fallback's setUserRole returns ok=false and we just log; the legacy
93+
// OrgMember.role write is the source of truth for OSS auth.
94+
const roleResult = await rbac.setUserRole({
95+
userId,
96+
organizationId: organization.id,
97+
roleId: SYSTEM_ROLE_IDS.owner,
98+
});
99+
if (!roleResult.ok) {
100+
logger.debug("createOrganization: skipped RBAC role assignment", {
101+
organizationId: organization.id,
102+
userId,
103+
reason: roleResult.error,
104+
});
105+
}
106+
85107
return { ...organization };
86108
}
87109

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,16 @@ export const rbac = plugin.create(
1515
{ getSessionUserId },
1616
{ forceFallback: env.RBAC_FORCE_FALLBACK }
1717
);
18+
19+
// Stable IDs for the system roles seeded by the enterprise/db migration
20+
// (cloud/enterprise/db/drizzle/migrations/0000_legal_titanium_man.sql).
21+
// They never change — anything that needs to set a default role at
22+
// creation time keys off these. The OSS 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+
member: "sys_role_member",
29+
viewer: "sys_role_viewer",
30+
} as const;

0 commit comments

Comments
 (0)