Skip to content

Commit 47904d1

Browse files
committed
fix(webapp): harden RBAC org scoping and write-tier gating
Resolve the RBAC organization from the primary so the role check is never evaluated without an org scope under replica lag: run cancel/replay fall back to the primary when the replica and buffer both miss, and the bulk-action routes read the org from the primary directly. Pin the buffered replay environment lookup to the buffer entry org so a malformed entry cannot resolve an environment in another org. Gate the seat-purchase modal on billing permission on the invite page, and gate environment-variable creation on write access rather than read access.
1 parent 625c8fe commit 47904d1

7 files changed

Lines changed: 58 additions & 12 deletions

File tree

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const loader = dashboardLoader(
5858
message: "With your current role, you can't invite team members.",
5959
},
6060
},
61-
async ({ user, context }) => {
61+
async ({ user, context, ability }) => {
6262
const organizationId = context.organizationId;
6363
if (!organizationId) {
6464
throw new Response("Not Found", { status: 404 });
@@ -98,7 +98,11 @@ export const loader = dashboardLoader(
9898
.map((r) => r.id)
9999
: [];
100100

101-
return typedjson({ ...result, offerableRoleIds });
101+
// Buying seats is a billing operation: surface whether this user can, so
102+
// the purchase modal disables its trigger (the team action enforces it).
103+
const canManageBilling = ability.can("manage", { type: "billing" });
104+
105+
return typedjson({ ...result, offerableRoleIds, canManageBilling });
102106
}
103107
);
104108

@@ -269,6 +273,7 @@ export default function Page() {
269273
planSeatLimit,
270274
roles,
271275
offerableRoleIds,
276+
canManageBilling,
272277
} = useTypedLoaderData<typeof loader>();
273278
const [total, setTotal] = useState(limits.used);
274279
const organization = useOrganization();
@@ -324,6 +329,7 @@ export default function Page() {
324329
usedSeats={limits.used}
325330
maxQuota={maxSeatQuota}
326331
planSeatLimit={planSeatLimit}
332+
canManageBilling={canManageBilling}
327333
triggerButton={<Button variant="primary/small">Purchase more seats…</Button>}
328334
/>
329335
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
2626
import { findProjectBySlug } from "~/models/project.server";
2727
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
2828
import { BulkActionPresenter } from "~/presenters/v3/BulkActionPresenter.server";
29-
import { $replica } from "~/db.server";
29+
import { prisma } from "~/db.server";
3030
import { logger } from "~/services/logger.server";
3131
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
3232
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
@@ -46,7 +46,10 @@ const BulkActionParamSchema = EnvironmentParamSchema.extend({
4646
});
4747

4848
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
49-
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
49+
// Resolve from the primary (not the replica) so the RBAC auth scope is never
50+
// resolved without an org under replica lag, which would let the role check
51+
// run unscoped under the enterprise plugin.
52+
const org = await prisma.organization.findFirst({ where: { slug }, select: { id: true } });
5053
return org?.id ?? null;
5154
}
5255

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,9 @@ export default function Page() {
212212
parentData,
213213
"Environment variables page loader data must be defined when rendering the create dialog"
214214
);
215-
const { environments, hasStaging, accessibleEnvironmentIds } = parentData;
216-
const accessibleEnvironmentIdSet = new Set(accessibleEnvironmentIds);
215+
const { environments, hasStaging, writableEnvironmentIds } = parentData;
216+
// Creating a variable is a write, so gate the targets on write access.
217+
const writableEnvironmentIdSet = new Set(writableEnvironmentIds);
217218
const lastSubmission = useActionData();
218219
const navigation = useNavigation();
219220
const navigate = useNavigate();
@@ -310,7 +311,7 @@ export default function Page() {
310311
)}
311312
<div className="flex items-center gap-2">
312313
{nonBranchEnvironments.map((environment) =>
313-
accessibleEnvironmentIdSet.has(environment.id) ? (
314+
writableEnvironmentIdSet.has(environment.id) ? (
314315
<CheckboxWithLabel
315316
key={environment.id}
316317
id={environment.id}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ export const loader = dashboardLoader(
149149
.map((env) => env.id);
150150
const accessible = new Set(accessibleEnvironmentIds);
151151

152+
// Write access is a separate grant from read: gate write controls (edit,
153+
// delete, create) on this set, not on read-accessibility.
154+
const writableEnvironmentIds = environments
155+
.filter((env) => ability.can("write", { type: "envvars", envType: env.type }))
156+
.map((env) => env.id);
157+
152158
// Withhold values (and the "who/when" metadata) for environments the
153159
// role can't read — never serialize them to the client.
154160
const masked: MaskedEnvironmentVariable[] = environmentVariables.map((variable) =>
@@ -170,6 +176,7 @@ export const loader = dashboardLoader(
170176
hasStaging,
171177
vercelIntegration,
172178
accessibleEnvironmentIds,
179+
writableEnvironmentIds,
173180
});
174181
} catch (error) {
175182
console.error(error);

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { findProjectBySlug } from "~/models/project.server";
5151
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
5252
import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server";
5353
import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
54-
import { $replica } from "~/db.server";
54+
import { $replica, prisma } from "~/db.server";
5555
import { logger } from "~/services/logger.server";
5656
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
5757
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
@@ -60,7 +60,10 @@ import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder";
6060
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
6161

6262
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
63-
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
63+
// Resolve from the primary (not the replica) so the RBAC auth scope is never
64+
// resolved without an org under replica lag, which would let the role check
65+
// run unscoped under the enterprise plugin.
66+
const org = await prisma.organization.findFirst({ where: { slug }, select: { id: true } });
6467
return org?.id ?? null;
6568
}
6669

apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,19 @@ async function resolveRunOrganizationId(runParam: string): Promise<string | null
3030

3131
const buffer = getMollifierBuffer();
3232
const entry = buffer ? await buffer.getEntry(runParam) : null;
33-
return entry?.orgId ?? null;
33+
if (entry?.orgId) {
34+
return entry.orgId;
35+
}
36+
37+
// Replica lag with the buffer entry already drained: the run can exist in
38+
// the primary while both lookups above miss. Fall back to the primary so the
39+
// RBAC scope is never resolved without an org (which would let the role check
40+
// run unscoped under the enterprise plugin).
41+
const primaryRun = await prisma.taskRun.findFirst({
42+
where: { friendlyId: runParam },
43+
select: { project: { select: { organizationId: true } } },
44+
});
45+
return primaryRun?.project.organizationId ?? null;
3446
}
3547

3648
export const action = dashboardAction(

apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,19 @@ async function resolveRunOrganizationId(runParam: string): Promise<string | null
267267

268268
const buffer = getMollifierBuffer();
269269
const entry = buffer ? await buffer.getEntry(runParam) : null;
270-
return entry?.orgId ?? null;
270+
if (entry?.orgId) {
271+
return entry.orgId;
272+
}
273+
274+
// Replica lag with the buffer entry already drained: the run can exist in
275+
// the primary while both lookups above miss. Fall back to the primary so the
276+
// RBAC scope is never resolved without an org (which would let the role check
277+
// run unscoped under the enterprise plugin).
278+
const primaryRun = await prisma.taskRun.findFirst({
279+
where: { friendlyId: runParam },
280+
select: { project: { select: { organizationId: true } } },
281+
});
282+
return primaryRun?.project.organizationId ?? null;
271283
}
272284

273285
export const action = dashboardAction(
@@ -351,7 +363,9 @@ export const action = dashboardAction(
351363
});
352364
if (synthetic) {
353365
const envRow = await prisma.runtimeEnvironment.findFirst({
354-
where: { id: entry.envId },
366+
// Pin to the buffer entry's org so a malformed entry can't
367+
// resolve an environment in a different org.
368+
where: { id: entry.envId, project: { organizationId: entry.orgId } },
355369
select: {
356370
slug: true,
357371
project: { select: { slug: true, organization: { select: { slug: true } } } },

0 commit comments

Comments
 (0)