Skip to content

Commit b39c4cd

Browse files
hanapotskiclaude
andcommitted
fix(security): scope profile-update override to global_admin only
Address PR review: admin/security_admin are per-tenant roles (granted via the login_tenant join) but the JWT does not encode which tenant they apply to, so honoring them as an override let an admin of one tenant edit users in another tenant (cross-tenant privilege escalation). Restrict the override to global_admin, which is a login-level, non-tenant-scoped flag and safe to trust from the JWT. Also reuse the shared getUserRoles helper from stakeholder-access instead of duplicating the role-parsing, so the two authorization checks can't drift apart. Tests: global_admin can still edit another user; a per-tenant admin (admin/security_admin without global_admin) is now blocked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 699fe58 commit b39c4cd

3 files changed

Lines changed: 47 additions & 15 deletions

File tree

server/__test__/account.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -712,11 +712,11 @@ describe("Account", () => {
712712
expect(res.status).toHaveBeenCalledWith(403);
713713
});
714714

715-
it("lets an admin update another user's profile", async () => {
715+
it("lets a global_admin update another user's profile", async () => {
716716
const res = mockResponse();
717717
const req = mockRequest({
718718
params: { userid: "999" },
719-
user: { id: 123, email: "admin@test.com", sub: "admin,global_admin" },
719+
user: { id: 123, email: "admin@test.com", sub: "global_admin" },
720720
body: {
721721
firstName: "New",
722722
lastName: "Name",
@@ -739,4 +739,33 @@ describe("Account", () => {
739739
expect(updateUserProfileMock).toHaveBeenCalledTimes(1);
740740
expect(res.status).toHaveBeenCalledWith(200);
741741
});
742+
743+
it("blocks a per-tenant admin from updating another user's profile (cross-tenant escalation)", async () => {
744+
const res = mockResponse();
745+
const req = mockRequest({
746+
params: { userid: "999" },
747+
// admin/security_admin are per-tenant roles; the JWT does not encode which
748+
// tenant they apply to, so they must NOT grant a cross-user override.
749+
user: {
750+
id: 123,
751+
email: "tenantadmin@test.com",
752+
sub: "admin,security_admin",
753+
},
754+
body: {
755+
firstName: "Attacker",
756+
lastName: "Controlled",
757+
email: "victim-new@test.com",
758+
tenantId: "1",
759+
},
760+
});
761+
const next = mockNext();
762+
const updateUserProfileMock =
763+
accountService.updateUserProfile as jest.MockedFunction<
764+
typeof accountService.updateUserProfile
765+
>;
766+
767+
await accountController.updateUserProfile(req, res, next);
768+
expect(updateUserProfileMock).not.toHaveBeenCalled();
769+
expect(res.status).toHaveBeenCalledWith(403);
770+
});
742771
});

server/app/controllers/account-controller.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
User,
99
Role,
1010
} from "../../types/account-types";
11+
import { getUserRoles } from "../helpers/stakeholder-access";
1112

1213
const getAll: RequestHandler<
1314
never,
@@ -271,18 +272,20 @@ const updateUserProfile: RequestHandler<
271272
> = async (req, res) => {
272273
const userid = req.params.userid;
273274

274-
// Authorization: a user may only update their own profile, unless they hold
275-
// an account-management admin role. Prevents the IDOR where any authenticated
276-
// (or, previously, unauthenticated) caller could overwrite any user's profile
277-
// -- including the email tied to their login (security audit finding #2).
278-
const roles = new Set(
279-
(req.user?.sub || req.user?.role || "").split(",").filter(Boolean)
280-
);
281-
const isAccountAdmin =
282-
roles.has("admin") ||
283-
roles.has("security_admin") ||
284-
roles.has("global_admin");
285-
if (String(req.user?.id) !== String(userid) && !isAccountAdmin) {
275+
// Authorization: a user may only update their own profile, unless they are a
276+
// global_admin. Prevents the IDOR where any authenticated (or, previously,
277+
// unauthenticated) caller could overwrite any user's profile -- including the
278+
// email tied to their login (security audit finding #2).
279+
//
280+
// Only global_admin is allowed to override, NOT admin/security_admin: those
281+
// are per-tenant roles (from the login_tenant join) but the JWT does not
282+
// encode which tenant they were granted for, so honoring them here would let
283+
// an admin of one tenant edit users in another tenant (cross-tenant privilege
284+
// escalation). global_admin is a login-level, non-tenant-scoped flag, so it is
285+
// safe to trust from the JWT.
286+
const roles = getUserRoles(req.user);
287+
const isGlobalAdmin = roles.has("global_admin");
288+
if (String(req.user?.id) !== String(userid) && !isGlobalAdmin) {
286289
return res.status(403).json({
287290
isSuccess: false,
288291
code: "FORBIDDEN",

server/app/helpers/stakeholder-access.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type StakeholderAccessRequest = {
1818
};
1919
};
2020

21-
const getUserRoles = (user?: StakeholderAccessUser) =>
21+
export const getUserRoles = (user?: StakeholderAccessUser) =>
2222
new Set((user?.sub || user?.role || "").split(",").filter(Boolean));
2323

2424
const isRestrictedDataEntryUser = (user?: StakeholderAccessUser) => {

0 commit comments

Comments
 (0)