Skip to content

Commit 7fa8f95

Browse files
d-cscarderne
authored andcommitted
fix(webapp): account & access-control hardening (auth, RBAC, org membership, impersonation) (#42)
1 parent 9dc737f commit 7fa8f95

24 files changed

Lines changed: 907 additions & 212 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Harden account and access-control handling across auth, RBAC, org membership, and impersonation

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

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -74,43 +74,6 @@ export async function getTeamMembersAndInvites({
7474
return { members: org.members, invites: org.invites };
7575
}
7676

77-
export async function removeTeamMember({
78-
userId,
79-
slug,
80-
memberId,
81-
}: {
82-
userId: string;
83-
slug: string;
84-
memberId: string;
85-
}) {
86-
const org = await prisma.organization.findFirst({
87-
where: { slug, members: { some: { userId } } },
88-
});
89-
90-
if (!org) {
91-
throw new Error("User does not have access to this organization");
92-
}
93-
94-
// Scope the target to this org. A member id is a globally unique key, so
95-
// deleting by id alone would remove members of other orgs; bind it to the
96-
// resolved org and reject a foreign id.
97-
const member = await prisma.orgMember.findFirst({
98-
where: { id: memberId, organizationId: org.id },
99-
include: {
100-
organization: true,
101-
user: true,
102-
},
103-
});
104-
105-
if (!member) {
106-
throw new Error("Member not found in this organization");
107-
}
108-
109-
await prisma.orgMember.delete({ where: { id: member.id } });
110-
111-
return member;
112-
}
113-
11477
export async function inviteMembers({
11578
slug,
11679
emails,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { PrismaClient } from "@trigger.dev/database";
2+
3+
// Leaf module with a type-only Prisma import (caller passes the client) so it
4+
// can be unit-tested without importing `~/db.server`, which eagerly connects
5+
// the global prisma singleton.
6+
export async function removeTeamMember(
7+
{
8+
userId,
9+
slug,
10+
memberId,
11+
}: {
12+
userId: string;
13+
slug: string;
14+
memberId: string;
15+
},
16+
prismaClient: PrismaClient
17+
) {
18+
const org = await prismaClient.organization.findFirst({
19+
where: { slug, members: { some: { userId } } },
20+
});
21+
22+
if (!org) {
23+
throw new Error("User does not have access to this organization");
24+
}
25+
26+
// Scope both the lookup and the delete to org.id, in a transaction, so the
27+
// member id is only ever resolved within the actor's organization.
28+
return prismaClient.$transaction(async (tx) => {
29+
const target = await tx.orgMember.findFirst({
30+
where: { id: memberId, organizationId: org.id },
31+
include: { organization: true, user: true },
32+
});
33+
34+
if (!target) {
35+
throw new Error("Member not found in this organization");
36+
}
37+
38+
await tx.orgMember.delete({ where: { id: target.id } });
39+
return target;
40+
});
41+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { DashboardPreferences } from "~/services/dashboardPreferences.serve
77
import { getDashboardPreferences } from "~/services/dashboardPreferences.server";
88
export type { User } from "@trigger.dev/database";
99
import { assertEmailAllowed } from "~/utils/email";
10+
import { emailMatchesPattern } from "~/utils/emailPattern";
1011
import { logger } from "~/services/logger.server";
1112

1213
type FindOrCreateMagicLink = {
@@ -74,8 +75,7 @@ export async function findOrCreateMagicLinkUser({
7475
},
7576
});
7677

77-
const adminEmailRegex = env.ADMIN_EMAILS ? new RegExp(env.ADMIN_EMAILS) : undefined;
78-
const makeAdmin = adminEmailRegex ? adminEmailRegex.test(email) : false;
78+
const makeAdmin = env.ADMIN_EMAILS ? emailMatchesPattern(env.ADMIN_EMAILS, email) : false;
7979

8080
const user = await prisma.user.upsert({
8181
where: {

apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import { redirect } from "remix-typedjson";
33
import { $replica } from "~/db.server";
44
import { clearImpersonation, redirectWithImpersonation } from "~/models/admin.server";
5+
import { env } from "~/env.server";
56
import { logger } from "~/services/logger.server";
67
import { requireUser } from "~/services/session.server";
8+
import { isSameOriginNavigation } from "~/utils/sameOriginNavigation";
79

810
export async function loader({ request, params }: LoaderFunctionArgs) {
911
const user = await requireUser(request);
@@ -29,6 +31,18 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
2931
return clearImpersonation(request, "/admin");
3032
}
3133

34+
// CSRF gate for the SET-impersonation path. Clearing impersonation
35+
// above is benign and stays reachable without the check.
36+
if (!isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
37+
logger.warn("Refusing cross-site impersonation entry", {
38+
userId: user.id,
39+
organizationSlug,
40+
referer: request.headers.get("referer"),
41+
secFetchSite: request.headers.get("sec-fetch-site"),
42+
});
43+
return redirect("/admin");
44+
}
45+
3246
const org = await $replica.organization.findFirst({
3347
where: {
3448
slug: organizationSlug,

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

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { rbac } from "~/services/rbac.server";
3636
import { ssoController } from "~/services/sso.server";
3737
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
3838
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
39+
import { isAtOrBelow } from "~/utils/inviteRoleLadder";
3940
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
4041

4142
const Params = z.object({
@@ -109,43 +110,6 @@ export const loader = dashboardLoader(
109110
// dropdown is hidden) or as a defensive default.
110111
const NO_RBAC_ROLE = "__no_rbac_role__";
111112

112-
// An inviter can only assign a role at or below their own. The
113-
// plugin's systemRoles array is in canonical order (highest authority
114-
// first), so array index drives the ladder — earlier index = higher
115-
// rank. Plan-tier filtering happens separately via assignableRoleIds;
116-
// the ladder is the absolute hierarchy. Custom roles aren't in the
117-
// ladder yet, so they're refused for now.
118-
type LadderRole = { id: string };
119-
120-
function buildRoleLevel(roles: ReadonlyArray<LadderRole>): Record<string, number> {
121-
const level: Record<string, number> = {};
122-
roles.forEach((r, i) => {
123-
// Top of the array = highest level. Subtract from length so larger
124-
// numbers always mean "more authority" — no off-by-one when a role
125-
// is added or removed.
126-
level[r.id] = roles.length - i;
127-
});
128-
return level;
129-
}
130-
131-
function isAtOrBelow(
132-
roles: ReadonlyArray<LadderRole>,
133-
inviterRoleId: string | null,
134-
invitedRoleId: string
135-
): boolean {
136-
// No resolvable role for the inviter → fail closed: we can't confirm a
137-
// target role is at or below an unknown level, so refuse it. The invite
138-
// itself still proceeds (it's gated by manage:members); only assigning an
139-
// explicit role is refused, and the picker offers nothing in this case.
140-
if (!inviterRoleId) return false;
141-
const level = buildRoleLevel(roles);
142-
const inviter = level[inviterRoleId];
143-
const invited = level[invitedRoleId];
144-
// Custom roles aren't in the level table — refuse.
145-
if (inviter === undefined || invited === undefined) return false;
146-
return invited <= inviter;
147-
}
148-
149113
const schema = z.object({
150114
emails: z.preprocess((i) => {
151115
if (typeof i === "string") return [i];

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ import * as Property from "~/components/primitives/PropertyTable";
4646
import { Select, SelectItem, SelectLinkItem } from "~/components/primitives/Select";
4747
import { SpinnerWhite } from "~/components/primitives/Spinner";
4848
import { SimpleTooltip } from "~/components/primitives/Tooltip";
49-
import { $replica } from "~/db.server";
49+
import { $replica, prisma } from "~/db.server";
5050
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
5151
import { useOrganization } from "~/hooks/useOrganizations";
5252
import { useUser } from "~/hooks/useUser";
53-
import { removeTeamMember } from "~/models/member.server";
53+
import { removeTeamMember } from "~/models/removeTeamMember.server";
5454
import { redirectWithSuccessMessage } from "~/models/message.server";
5555
import { resolveOrgIdFromSlug } from "~/models/organization.server";
5656
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
@@ -262,12 +262,9 @@ export const action = dashboardAction(
262262
return json(submission.reply());
263263
}
264264

265-
// Default intent: remove a member or leave the org. Scope the target to
266-
// the actor's organization: an orgMember id is a globally unique key, so an
267-
// unscoped lookup (plus an unscoped delete in the model) would let a
268-
// manager in one org remove members of another by submitting a foreign id.
269-
// Self-leave is always allowed; removing someone else requires
270-
// manage:members.
265+
// Default intent: remove a member or leave the org, with the target scoped
266+
// to the actor's organization. Self-leave is always allowed; removing
267+
// someone else requires manage:members.
271268
const orgId = context.organizationId;
272269
if (!orgId) {
273270
return json({ ok: false, error: "Organization not found" } as const, { status: 404 });
@@ -295,11 +292,14 @@ export const action = dashboardAction(
295292
}
296293

297294
try {
298-
const deletedMember = await removeTeamMember({
299-
userId,
300-
memberId: submission.value.memberId,
301-
slug: organizationSlug,
302-
});
295+
const deletedMember = await removeTeamMember(
296+
{
297+
userId,
298+
memberId: submission.value.memberId,
299+
slug: organizationSlug,
300+
},
301+
prisma
302+
);
303303

304304
// Sticky removal: record a tombstone so passive SSO-JIT won't re-add
305305
// them on next login (best-effort; no-op without the SSO plugin).

apps/webapp/app/routes/login.mfa/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
redirectWithErrorMessage,
2626
} from "~/models/message.server";
2727
import { authenticator } from "~/services/auth.server";
28-
import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server";
28+
import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiterGlobal.server";
2929
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
3030
import { trackAndClearReferralSource } from "~/services/referralSource.server";
3131
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { GoogleStrategy } from "remix-auth-google";
33
import { env } from "~/env.server";
44
import { findOrCreateUser } from "~/models/user.server";
55
import type { AuthUser } from "./authUser";
6+
import { isGoogleEmailVerified } from "./googleEmailVerification";
67
import { logger } from "./logger.server";
78
import { postAuthentication } from "./postAuth.server";
89
import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server";
@@ -27,6 +28,14 @@ export function addGoogleStrategy(
2728

2829
const email = emails[0].value;
2930

31+
// Only trust the email if Google asserts it's verified, since account
32+
// linking keys off it. See isGoogleEmailVerified.
33+
if (!isGoogleEmailVerified(profile)) {
34+
throw new Error(
35+
"Google login refused: the Google account's email is not verified. Sign in with an account whose email Google has verified, or use magic-link / GitHub."
36+
);
37+
}
38+
3039
// SSO auto-discovery gate — BEFORE findOrCreateUser, so an
3140
// SSO-enforced domain never gets this Google identity linked onto
3241
// an existing account.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { GoogleProfile } from "remix-auth-google";
2+
3+
/**
4+
* Whether Google has asserted that the profile's email is verified. A
5+
* successful OAuth flow proves control of the Google account, not ownership of
6+
* the email it carries, and account linking keys off the email.
7+
*
8+
* Strict by design: only a real boolean `true` counts. A missing claim, missing
9+
* `_json`, the string `"true"`, or a truthy `1` are all treated as unverified.
10+
*/
11+
export function isGoogleEmailVerified(profile: GoogleProfile): boolean {
12+
const emailVerified = (profile as { _json?: { email_verified?: unknown } })?._json
13+
?.email_verified;
14+
return emailVerified === true;
15+
}

0 commit comments

Comments
 (0)