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:
2026-06-19 01:27:02 +02:00
parent d0841c8601
commit ef0ecadff9
3 changed files with 72 additions and 4 deletions
+21 -1
View File
@@ -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();