Skip to content

Commit 8adbd3d

Browse files
committed
fix(webapp): fail closed when a scoped authorization check has no org scope
The dashboard route builder resolves an org/project scope in each route context and runs the authorization check against it. When the scope could not be resolved the check evaluated an unscoped ability, so it silently became a no-op for a missing org. The builder now treats a scoped authorization block with no resolved org or project as a denial (requireSuper stays global), so the check can never pass unscoped. Add a shared resolveOrgIdFromSlug that reads the replica first and falls back to the primary on a miss, and use it across the dashboard routes, so replica lag never leaves a real org unresolved.
1 parent 43bdfd6 commit 8adbd3d

23 files changed

Lines changed: 78 additions & 155 deletions

File tree

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
import { customAlphabet } from "nanoid";
1010
import { generate } from "random-words";
1111
import slug from "slug";
12-
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
12+
import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
1313
import { env } from "~/env.server";
1414
import { featuresForUrl } from "~/features.server";
1515
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
@@ -18,6 +18,28 @@ export type { Organization };
1818

1919
const nanoid = customAlphabet("1234567890abcdef", 4);
2020

21+
/**
22+
* Resolve an organization id from its slug for use as an RBAC auth scope.
23+
* Reads the replica first (the common case) and falls back to the primary on a
24+
* miss, so replica lag never leaves a real org unresolved, which the dashboard
25+
* route builder treats as an unauthorized request.
26+
*/
27+
export async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
28+
const fromReplica = await $replica.organization.findFirst({
29+
where: { slug },
30+
select: { id: true },
31+
});
32+
if (fromReplica) {
33+
return fromReplica.id;
34+
}
35+
36+
const fromPrimary = await prisma.organization.findFirst({
37+
where: { slug },
38+
select: { id: true },
39+
});
40+
return fromPrimary?.id ?? null;
41+
}
42+
2143
export async function createOrganization(
2244
{
2345
title,

apps/webapp/app/routes/_app.github.install/route.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { redirect } from "remix-typedjson";
22
import { z } from "zod";
33
import { $replica } from "~/db.server";
4+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
45
import { createGitHubAppInstallSession } from "~/services/gitHubSession.server";
56
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
67
import { newOrganizationPath } from "~/utils/pathBuilder";
@@ -14,11 +15,6 @@ const QuerySchema = z.object({
1415
}),
1516
});
1617

17-
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
18-
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
19-
return org?.id ?? null;
20-
}
21-
2218
export const loader = dashboardLoader(
2319
{
2420
// The org for the auth scope comes from the `org_slug` query param.

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { env } from "~/env.server";
2929
import { useOrganization } from "~/hooks/useOrganizations";
3030
import { inviteMembers } from "~/models/member.server";
3131
import { redirectWithSuccessMessage } from "~/models/message.server";
32+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
3233
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
3334
import { scheduleEmail } from "~/services/scheduleEmail.server";
3435
import { rbac } from "~/services/rbac.server";
@@ -40,11 +41,6 @@ const Params = z.object({
4041
organizationSlug: z.string(),
4142
});
4243

43-
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
44-
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
45-
return org?.id ?? null;
46-
}
47-
4844
export const loader = dashboardLoader(
4945
{
5046
params: Params,

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ import { InputGroup } from "~/components/primitives/InputGroup";
3131
import { Label } from "~/components/primitives/Label";
3232
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
3333
import * as Property from "~/components/primitives/PropertyTable";
34-
import { $replica } from "~/db.server";
3534
import { useOrganization } from "~/hooks/useOrganizations";
35+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
3636
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
3737
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
3838
import { cn } from "~/utils/cn";
@@ -46,11 +46,6 @@ export const meta: MetaFunction = () => {
4646
];
4747
};
4848

49-
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
50-
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
51-
return org?.id ?? null;
52-
}
53-
5449
export const loader = dashboardLoader(
5550
{
5651
params: EnvironmentParamSchema,

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ import { useEnvironment } from "~/hooks/useEnvironment";
2323
import { useOrganization } from "~/hooks/useOrganizations";
2424
import { useProject } from "~/hooks/useProject";
2525
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
26+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
2627
import { findProjectBySlug } from "~/models/project.server";
2728
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
2829
import { BulkActionPresenter } from "~/presenters/v3/BulkActionPresenter.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";
@@ -45,14 +45,6 @@ const BulkActionParamSchema = EnvironmentParamSchema.extend({
4545
bulkActionParam: z.string(),
4646
});
4747

48-
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
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 } });
53-
return org?.id ?? null;
54-
}
55-
5648
export const loader = dashboardLoader(
5749
{
5850
params: BulkActionParamSchema,

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ import {
6666
import { useEnvironment } from "~/hooks/useEnvironment";
6767
import { useOrganization } from "~/hooks/useOrganizations";
6868
import { useProject } from "~/hooks/useProject";
69+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
6970
import {
7071
type DeploymentListItem,
7172
DeploymentListPresenter,
7273
} from "~/presenters/v3/DeploymentListPresenter.server";
7374
import { requireUserId } from "~/services/session.server";
74-
import { $replica } from "~/db.server";
7575
import { rbac } from "~/services/rbac.server";
7676
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
7777
import { titleCase } from "~/utils";
@@ -101,11 +101,6 @@ const SearchParams = z.object({
101101
version: z.string().optional(),
102102
});
103103

104-
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
105-
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
106-
return org?.id ?? null;
107-
}
108-
109104
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
110105
const userId = await requireUserId(request);
111106
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { useList } from "~/hooks/useList";
4747
import { useOrganization } from "~/hooks/useOrganizations";
4848
import { useProject } from "~/hooks/useProject";
4949
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
50+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
5051
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
5152
import { cn } from "~/utils/cn";
5253
import {
@@ -101,11 +102,6 @@ const schema = z.object({
101102
}, Variable.array().nonempty("At least one variable is required")),
102103
});
103104

104-
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
105-
const org = await prisma.organization.findFirst({ where: { slug }, select: { id: true } });
106-
return org?.id ?? null;
107-
}
108-
109105
export const action = dashboardAction(
110106
{
111107
params: EnvironmentParamSchema,

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { useSearchParams } from "~/hooks/useSearchParam";
5959
import { useOrganization } from "~/hooks/useOrganizations";
6060
import { useProject } from "~/hooks/useProject";
6161
import { redirectWithSuccessMessage } from "~/models/message.server";
62+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
6263
import {
6364
type EnvironmentVariableWithSetValues,
6465
EnvironmentVariablesPresenter,
@@ -118,11 +119,6 @@ export type EnvironmentVariablesPageLoaderData = {
118119
export const environmentVariablesRouteId =
119120
"routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables";
120121

121-
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
122-
const org = await prisma.organization.findFirst({ where: { slug }, select: { id: true } });
123-
return org?.id ?? null;
124-
}
125-
126122
export const loader = dashboardLoader(
127123
{
128124
params: EnvironmentParamSchema,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.prompts.$promptSlug/route.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,13 @@ import { Spinner } from "~/components/primitives/Spinner";
5656
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
5757
import { TextArea } from "~/components/primitives/TextArea";
5858
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
59-
import { $replica, prisma } from "~/db.server";
59+
import { prisma } from "~/db.server";
6060
import { useEnvironment } from "~/hooks/useEnvironment";
6161
import { useInterval } from "~/hooks/useInterval";
6262
import { useOrganization } from "~/hooks/useOrganizations";
6363
import { useProject } from "~/hooks/useProject";
6464
import { useSearchParams } from "~/hooks/useSearchParam";
65+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
6566
import { findProjectBySlug } from "~/models/project.server";
6667
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
6768
import { type GenerationRow, PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
@@ -121,11 +122,6 @@ const ActionSchema = z.discriminatedUnion("intent", [
121122
}),
122123
]);
123124

124-
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
125-
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
126-
return org?.id ?? null;
127-
}
128-
129125
export const action = dashboardAction(
130126
{
131127
params: ParamSchema,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson"
66
import { z } from "zod";
77
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
88
import { throwPermissionDenied } from "~/utils/permissionDenied";
9-
import { $replica } from "~/db.server";
109
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
1110
import { Button } from "~/components/primitives/Buttons";
1211
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
@@ -39,13 +38,9 @@ import {
3938
VercelOnboardingModal,
4039
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
4140
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
41+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
4242
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
4343

44-
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
45-
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
46-
return org?.id ?? null;
47-
}
48-
4944
export const loader = dashboardLoader(
5045
{
5146
params: EnvironmentParamSchema,

0 commit comments

Comments
 (0)