Skip to content

Commit 79856c7

Browse files
committed
RBAC: PAT creation flow with role selection (TRI-8749)
Lets users pick a system role at PAT-create time and persists it via enterprise.TokenRole so PAT-authenticated requests will run with that role's permissions once the auth-side wiring lands. V1 scope decisions (worth flagging for review): 1. System roles only. PATs are user-scoped (not org-scoped) and custom roles are per-org — the role-to-org mapping for a multi-org user's PAT is a non-trivial design question that doesn't need to be answered for v1. Show the four seeded system roles (Owner/Admin/Member/Viewer); a follow-up can add custom roles once we've decided what "this PAT uses an org X custom role" means semantically. 2. Default to caller's own role. Loader queries rbac.getUserRole against the user's first org membership (createdAt ASC) and uses that as the dropdown default — a PAT can't be more privileged than the person creating it without an explicit upgrade. Falls back to Member for users with no role assignment yet (OSS or new user pre-backfill). 3. No plan gating. Plan tiers are per-org; PAT roles are global. Plan gating only made sense in the org-scoped Teams page UI (TRI-8748). 4. No privilege-escalation check. Today's PATs run through the legacy auth path with full superScopes — even a "Owner" PAT here is strictly less permissive than the status quo. Locking down "the PAT can't exceed the creator's role" is a hardening for a later ticket once the read-side actually keys off TokenRole. Changes: - services/personalAccessToken.server.ts: createPersonalAccessToken takes an optional roleId. When provided, calls rbac.setTokenRole after the Prisma PAT row is created. On a real failure the PAT is compensating-deleted (the two writes live on different ORMs sharing one connection — co-transactions are awkward, compensating delete is simpler). The OSS fallback's "RBAC plugin not installed" return is treated as success-with-no-role: the PAT row stays, just no TokenRole gets written, matching pre-RBAC behaviour. - routes/account.tokens/route.tsx: loader fetches system roles + caller's current role; create form shows a role <Select> with the caller's role as default; OSS path (allRoles returns []) hides the dropdown entirely. Action passes roleId through to the service. Out of scope here (covered elsewhere): - The PAT auth-side path that will JOIN TokenRole and build an ability from the role's permissions. Lives in the enterprise plugin's authenticatePat path; tracked under the TRI-8741 test surface and the broader auth-consolidation work in TRI-8744. - CLI auth-code PAT (createPersonalAccessTokenFromAuthorizationCode) unchanged. CLI PATs continue to be created without an explicit role — they go through the legacy permissive path and existing user expectations of "trigger dev just works" are preserved. Verification: typecheck clean on webapp. Browser smoke test deferred to your local run.
1 parent ac55499 commit 79856c7

3 files changed

Lines changed: 172 additions & 4 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
RBAC: PAT creation flow now lets users pick a system role at create
7+
time, persisted as an enterprise.TokenRole row (TRI-8749). Defaults to
8+
the caller's own role so a PAT can't be more privileged than the
9+
person creating it. Custom (org-defined) roles are out of scope for
10+
v1 — only the four global system roles are offered, and the binding
11+
is global to the PAT regardless of which org the request later
12+
targets. Compensating-delete pattern on TokenRole insert failure
13+
keeps the two writes (Prisma PAT row + Drizzle TokenRole row)
14+
consistent without cross-ORM transaction wrestling. OSS path is a
15+
no-op: when the RBAC plugin isn't installed the dropdown is hidden,
16+
no roleId is submitted, and the PAT works exactly as before.

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

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
55
import { DialogClose } from "@radix-ui/react-dialog";
66
import { Form, type MetaFunction, useActionData, useFetcher } from "@remix-run/react";
77
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
8+
import { useState } from "react";
89
import { typedjson, useTypedLoaderData } from "remix-typedjson";
910
import { z } from "zod";
1011
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
@@ -22,6 +23,7 @@ import { InputGroup } from "~/components/primitives/InputGroup";
2223
import { Label } from "~/components/primitives/Label";
2324
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
2425
import { Paragraph } from "~/components/primitives/Paragraph";
26+
import { Select, SelectItem } from "~/components/primitives/Select";
2527
import {
2628
Table,
2729
TableBlankRow,
@@ -34,6 +36,8 @@ import {
3436
} from "~/components/primitives/Table";
3537
import { SimpleTooltip } from "~/components/primitives/Tooltip";
3638
import { redirectWithSuccessMessage } from "~/models/message.server";
39+
import { prisma } from "~/db.server";
40+
import { rbac, SYSTEM_ROLE_IDS } from "~/services/rbac.server";
3741
import {
3842
type CreatedPersonalAccessToken,
3943
type ObfuscatedPersonalAccessToken,
@@ -52,14 +56,52 @@ export const meta: MetaFunction = () => {
5256
];
5357
};
5458

59+
// PATs aren't org-scoped, but role/permission catalogues are seeded
60+
// per-org in enterprise's allRoles. To get the canonical system roles
61+
// (Owner/Admin/Member/Viewer — orgId IS NULL on those rows), we hand
62+
// allRoles any orgId the user belongs to and filter down to system
63+
// roles. This is a UI-only convenience — the chosen role becomes a
64+
// global TokenRole row that applies wherever the PAT is used. Custom
65+
// (org-defined) roles are out of scope for v1: their org-binding
66+
// semantics for a multi-org user's PAT need a separate design pass.
67+
async function loadSystemRolesForUser(userId: string) {
68+
const orgMember = await prisma.orgMember.findFirst({
69+
where: { userId },
70+
select: { organizationId: true },
71+
orderBy: { createdAt: "asc" },
72+
});
73+
if (!orgMember) return { roles: [], userRoleId: null as string | null };
74+
75+
const allRoles = await rbac.allRoles(orgMember.organizationId);
76+
const systemRoles = allRoles.filter((r) => r.isSystem);
77+
78+
const userRole = await rbac.getUserRole({
79+
userId,
80+
organizationId: orgMember.organizationId,
81+
});
82+
83+
return { roles: systemRoles, userRoleId: userRole?.id ?? null };
84+
}
85+
5586
export const loader = async ({ request }: LoaderFunctionArgs) => {
5687
const userId = await requireUserId(request);
5788

5889
try {
59-
const personalAccessTokens = await getValidPersonalAccessTokens(userId);
90+
const [personalAccessTokens, { roles, userRoleId }] = await Promise.all([
91+
getValidPersonalAccessTokens(userId),
92+
loadSystemRolesForUser(userId),
93+
]);
94+
95+
// Default the role picker to the user's own role in their primary
96+
// org so a freshly-created PAT isn't more privileged than the
97+
// person creating it. Falls back to Member if they don't have one
98+
// (new user, OSS path with no role assignments yet).
99+
const defaultRoleId = userRoleId ?? SYSTEM_ROLE_IDS.member;
60100

61101
return typedjson({
62102
personalAccessTokens,
103+
roles,
104+
defaultRoleId,
63105
});
64106
} catch (error) {
65107
if (error instanceof Response) {
@@ -81,6 +123,11 @@ const CreateTokenSchema = z.discriminatedUnion("action", [
81123
.string({ required_error: "You must enter a name" })
82124
.min(2, "Your name must be at least 2 characters long")
83125
.max(50),
126+
// Optional — when the RBAC plugin isn't installed (OSS), the UI
127+
// hides the dropdown and submits no roleId; the action passes that
128+
// through and createPersonalAccessToken just doesn't write a
129+
// TokenRole row.
130+
roleId: z.string().optional(),
84131
}),
85132
z.object({
86133
action: z.literal("revoke"),
@@ -103,6 +150,7 @@ export const action: ActionFunction = async ({ request }) => {
103150
const tokenResult = await createPersonalAccessToken({
104151
name: submission.value.tokenName,
105152
userId,
153+
roleId: submission.value.roleId,
106154
});
107155

108156
return json({ ...submission, payload: { token: tokenResult } });
@@ -131,7 +179,7 @@ export const action: ActionFunction = async ({ request }) => {
131179
};
132180

133181
export default function Page() {
134-
const { personalAccessTokens } = useTypedLoaderData<typeof loader>();
182+
const { personalAccessTokens, roles, defaultRoleId } = useTypedLoaderData<typeof loader>();
135183

136184
return (
137185
<PageContainer>
@@ -151,7 +199,7 @@ export default function Page() {
151199
</DialogTrigger>
152200
<DialogContent className="max-w-md">
153201
<DialogHeader>Create a Personal Access Token</DialogHeader>
154-
<CreatePersonalAccessToken />
202+
<CreatePersonalAccessToken roles={roles} defaultRoleId={defaultRoleId} />
155203
</DialogContent>
156204
</Dialog>
157205
</PageAccessories>
@@ -211,7 +259,15 @@ export default function Page() {
211259
);
212260
}
213261

214-
function CreatePersonalAccessToken() {
262+
type SystemRole = { id: string; name: string; description: string };
263+
264+
function CreatePersonalAccessToken({
265+
roles,
266+
defaultRoleId,
267+
}: {
268+
roles: SystemRole[];
269+
defaultRoleId: string;
270+
}) {
215271
const fetcher = useFetcher<typeof action>();
216272
const lastSubmission = fetcher.data as any;
217273

@@ -228,6 +284,13 @@ function CreatePersonalAccessToken() {
228284
? (lastSubmission?.payload?.token as CreatedPersonalAccessToken)
229285
: undefined;
230286

287+
// OSS path: rbac.allRoles returns []; we hide the dropdown entirely
288+
// rather than showing an empty Select. createPersonalAccessToken's
289+
// roleId is optional, so omitting it produces a working PAT with no
290+
// explicit role attached (matches pre-RBAC behaviour).
291+
const showRolePicker = roles.length > 0;
292+
const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
293+
231294
return (
232295
<div className="max-w-full overflow-x-hidden">
233296
{token ? (
@@ -248,6 +311,7 @@ function CreatePersonalAccessToken() {
248311
) : (
249312
<fetcher.Form method="post" {...form.props}>
250313
<input type="hidden" name="action" value="create" />
314+
{showRolePicker && <input type="hidden" name="roleId" value={selectedRoleId} />}
251315
<Fieldset className="mt-3">
252316
<InputGroup>
253317
<Label htmlFor={tokenName.id}>Name</Label>
@@ -265,6 +329,37 @@ function CreatePersonalAccessToken() {
265329
<FormError id={tokenName.errorId}>{tokenName.error}</FormError>
266330
</InputGroup>
267331

332+
{showRolePicker && (
333+
<InputGroup>
334+
<Label>Role</Label>
335+
<Select<string, SystemRole>
336+
value={selectedRoleId}
337+
setValue={(v) => setSelectedRoleId(v)}
338+
items={roles}
339+
variant="tertiary/small"
340+
dropdownIcon
341+
text={(v) => roles.find((r) => r.id === v)?.name ?? "Select a role"}
342+
>
343+
{(items) =>
344+
items.map((role) => (
345+
<SelectItem key={role.id} value={role.id}>
346+
<span className="flex flex-col">
347+
<span>{role.name}</span>
348+
{role.description ? (
349+
<span className="text-xs text-text-dimmed">{role.description}</span>
350+
) : null}
351+
</span>
352+
</SelectItem>
353+
))
354+
}
355+
</Select>
356+
<Hint>
357+
The token's permissions are bound to this role. Defaults to your own role so the
358+
token can't do more than you can.
359+
</Hint>
360+
</InputGroup>
361+
)}
362+
268363
<FormButtons
269364
confirmButton={
270365
<Button type="submit" variant={"primary/small"}>

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,30 @@ import { customAlphabet, nanoid } from "nanoid";
33
import { z } from "zod";
44
import { prisma } from "~/db.server";
55
import { logger } from "./logger.server";
6+
import { rbac } from "./rbac.server";
67
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
78
import { env } from "~/env.server";
89

910
const tokenValueLength = 40;
1011
//lowercase only, removed 0 and l to avoid confusion
1112
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
1213

14+
// The OSS fallback's setTokenRole returns this exact string when no
15+
// enterprise plugin is loaded. We treat that as "no role attached" —
16+
// the PAT is still valid; auth just falls through to legacy permissive
17+
// behaviour. Any other error is treated as a real failure and triggers
18+
// the compensating delete below.
19+
const FALLBACK_NOT_INSTALLED_ERROR = "RBAC plugin not installed";
20+
1321
type CreatePersonalAccessTokenOptions = {
1422
name: string;
1523
userId: string;
24+
// Optional: when provided, persist a TokenRole row alongside the PAT
25+
// so PAT-authenticated requests pick up that role's permissions
26+
// (TRI-8749). The dashboard tokens page passes a chosen system role;
27+
// the CLI auth-code path doesn't pass one (legacy behaviour
28+
// preserved — those PATs run with no explicit role).
29+
roleId?: string;
1630
};
1731

1832
/** Returns obfuscated access tokens that aren't revoked */
@@ -322,6 +336,7 @@ export async function createPersonalAccessTokenFromAuthorizationCode(
322336
export async function createPersonalAccessToken({
323337
name,
324338
userId,
339+
roleId,
325340
}: CreatePersonalAccessTokenOptions) {
326341
const token = createToken();
327342
const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY);
@@ -336,6 +351,48 @@ export async function createPersonalAccessToken({
336351
},
337352
});
338353

354+
// Persist the role choice in enterprise.TokenRole. This lives on a
355+
// different schema (Drizzle, not Prisma) — co-transactional inserts
356+
// across the two ORMs are awkward, so we use a compensating-delete
357+
// pattern: if setTokenRole fails, roll back the PAT row by deleting
358+
// it. The auth path treats "no role" as permissive (matches OSS
359+
// fallback) so a brief orphan window between the two writes is
360+
// harmless. The compensating delete narrows that window from "until
361+
// manual cleanup" to "until the request returns".
362+
if (roleId) {
363+
const roleResult = await rbac.setTokenRole({
364+
tokenId: personalAccessToken.id,
365+
roleId,
366+
});
367+
if (!roleResult.ok) {
368+
// The OSS fallback always returns ok=false with this exact
369+
// message. That isn't a failure — there's no enterprise plugin
370+
// to write to, so the PAT just runs without an explicit role
371+
// (matches the pre-RBAC behaviour). Don't compensating-delete
372+
// in that case.
373+
if (roleResult.error === FALLBACK_NOT_INSTALLED_ERROR) {
374+
logger.debug("createPersonalAccessToken: no RBAC plugin, skipping role assignment", {
375+
patId: personalAccessToken.id,
376+
userId,
377+
});
378+
} else {
379+
await prisma.personalAccessToken
380+
.delete({ where: { id: personalAccessToken.id } })
381+
.catch((err) => {
382+
logger.error(
383+
"Failed to compensating-delete PAT after TokenRole insert failed",
384+
{
385+
patId: personalAccessToken.id,
386+
roleResultError: roleResult.error,
387+
deleteError: err instanceof Error ? err.message : String(err),
388+
}
389+
);
390+
});
391+
throw new Error(`Failed to assign role to access token: ${roleResult.error}`);
392+
}
393+
}
394+
}
395+
339396
return {
340397
id: personalAccessToken.id,
341398
name,

0 commit comments

Comments
 (0)