fix(auth): block privilege escalation via role/user management
The dynamic-RBAC management routes are themselves grantable (role:* and user:*), so a non-admin holding them could self-escalate: edit their own role to add a permission they lack, mint a privileged role, assign someone the admin role, or reset/delete a more-privileged account. Found by the commit security review (2× HIGH). Fix — enforce the RBAC invariant "you cannot grant beyond yourself": - roles.ts: role:create/update reject any permission not held by the caller (escalates()). An admin holds the full set, so it stays unrestricted. - users.ts: user:create/update reject assigning a role whose permissions exceed the caller's; update/password-reset/delete reject acting on a user whose current role exceeds the caller's (exceedsCaller()). The existing no-lockout + builtin-admin protections are unchanged. Verified: 10-assertion inject test — manager (role:* + user:* but no tariff:update, not admin) gets 403 on self-grant, minting a privileged role, assigning/resetting/deleting an admin; admin stays unrestricted; the manager can still create peers + in-scope roles (not over-blocked). Full build green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
import { bumpPermsCache, requirePermission } from "../auth.js";
|
||||
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||
|
||||
// Role management (admin). Roles are DATA: an admin composes a role from the
|
||||
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
|
||||
@@ -10,6 +10,13 @@ import { bumpPermsCache, requirePermission } from "../auth.js";
|
||||
// edited or deleted and always resolves to every permission in code. Every write
|
||||
// here bumps the in-memory permission cache so changes take effect on the next
|
||||
// request. See @parking/shared PERMISSIONS and ../auth.ts.
|
||||
//
|
||||
// PRIVILEGE-ESCALATION GUARD: `role:update`/`role:create` must NOT let a caller
|
||||
// grant a permission they don't themselves hold — otherwise a non-admin with
|
||||
// `role:*` could edit their own role to add (say) `tariff:update`, or mint a role
|
||||
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
|
||||
// only put permissions they ALREADY hold onto a role. An admin (full set) is
|
||||
// unrestricted, which is the intended behaviour.
|
||||
|
||||
interface RoleBody {
|
||||
name: string;
|
||||
@@ -77,6 +84,15 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
};
|
||||
});
|
||||
|
||||
/** Reject any permission in `perms` the caller does not themselves hold — so a
|
||||
* non-admin can't grant privileges beyond their own. Returns the offending
|
||||
* permission, or null if all are within the caller's set. (Admin holds the full
|
||||
* set, so it never trips.) */
|
||||
function escalates(callerRoleId: string, perms: Permission[]): Permission | null {
|
||||
const held = permissionsFor(callerRoleId);
|
||||
return perms.find((p) => !held.has(p)) ?? null;
|
||||
}
|
||||
|
||||
// Create a composable role from a name + a permission set.
|
||||
app.post<{ Body: RoleBody }>("/api/roles", { preHandler: createGuard }, async (req, reply) => {
|
||||
const name = (req.body?.name ?? "").trim();
|
||||
@@ -86,6 +102,8 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
const cleaned = cleanPermissions(req.body?.permissions ?? []);
|
||||
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
||||
const over = escalates(req.user.roleId, cleaned.perms);
|
||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||
|
||||
const id = randomUUID();
|
||||
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
||||
@@ -116,6 +134,8 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
if (req.body?.permissions != null) {
|
||||
const cleaned = cleanPermissions(req.body.permissions);
|
||||
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
||||
const over = escalates(req.user.roleId, cleaned.perms);
|
||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||
setPermissions(id, cleaned.perms);
|
||||
}
|
||||
bumpPermsCache();
|
||||
|
||||
Reference in New Issue
Block a user