ef0ecadff9
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
202 lines
8.9 KiB
TypeScript
202 lines
8.9 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import bcrypt from "bcrypt";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { eq, roles, users, type Db } from "@parking/db";
|
|
import { ADMIN_ROLE_ID } from "@parking/shared";
|
|
import { permissionsFor, requirePermission } from "../auth.js";
|
|
|
|
// User management (admin). Users are created/edited at runtime here — the
|
|
// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one
|
|
// role (RBAC); the role resolves to a permission set at request time. Passwords
|
|
// are bcrypt-hashed (cost 12) and never returned. See @parking/shared PERMISSIONS.
|
|
//
|
|
// NO-LOCKOUT INVARIANT: the app refuses to delete, or move off the `admin` role,
|
|
// the LAST user still holding `admin`. Administration can therefore never be
|
|
// locked out of the appliance. See wiki/entities/local-jwt-auth.md.
|
|
//
|
|
// PRIVILEGE-ESCALATION GUARD: a non-admin caller with `user:*` must NOT be able to
|
|
// (a) ASSIGN a role whose permissions exceed their own (e.g. hand themselves or a
|
|
// peer the admin role, or any role broader than theirs), nor (b) MODIFY a user who
|
|
// already holds a role broader than the caller's (resetting an admin's password is
|
|
// account takeover; deleting an admin is sabotage). Both are blocked below by
|
|
// comparing permission SETS. An admin holds the full set, so it is unrestricted.
|
|
|
|
interface CreateBody {
|
|
username: string;
|
|
password: string;
|
|
roleId: string;
|
|
}
|
|
interface UpdateBody {
|
|
username?: string;
|
|
roleId?: string;
|
|
}
|
|
interface PasswordBody {
|
|
password: string;
|
|
}
|
|
|
|
const MIN_PASSWORD = 8;
|
|
|
|
export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|
const readGuard = requirePermission("user:read");
|
|
const createGuard = requirePermission("user:create");
|
|
const updateGuard = requirePermission("user:update");
|
|
const deleteGuard = requirePermission("user:delete");
|
|
|
|
/** Count users currently holding the protected admin role. */
|
|
function adminCount(): number {
|
|
return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length;
|
|
}
|
|
|
|
/** True if removing/relocating `userId` from admin would leave zero admins. */
|
|
function isLastAdmin(userId: string): boolean {
|
|
const u = db.select().from(users).where(eq(users.id, userId)).get();
|
|
return u?.roleId === ADMIN_ROLE_ID && adminCount() <= 1;
|
|
}
|
|
|
|
/** A user row safe to return — never the password hash. */
|
|
function publicUser(u: { id: string; username: string; roleId: string; language: string; createdAt: string }) {
|
|
return { id: u.id, username: u.username, roleId: u.roleId, language: u.language, createdAt: u.createdAt };
|
|
}
|
|
|
|
/** True if `targetRoleId` grants any permission the caller's role does NOT hold,
|
|
* i.e. assigning or touching it would let the caller act beyond their own
|
|
* privileges. (Admin holds the full set, so it never trips.) */
|
|
function exceedsCaller(callerRoleId: string, targetRoleId: string): boolean {
|
|
if (callerRoleId === targetRoleId) return false;
|
|
const held = permissionsFor(callerRoleId);
|
|
for (const p of permissionsFor(targetRoleId)) {
|
|
if (!held.has(p)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// List all users (no password hashes) + their role names for display.
|
|
app.get("/api/users", { preHandler: readGuard }, async () => {
|
|
const rows = db.select().from(users).all();
|
|
const roleRows = db.select().from(roles).all();
|
|
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
|
|
return {
|
|
users: rows.map((u) => ({ ...publicUser(u), roleName: roleName.get(u.roleId) ?? u.roleId })),
|
|
};
|
|
});
|
|
|
|
// Create a user. Username unique; password >= 8 chars; roleId must exist.
|
|
app.post<{ Body: CreateBody }>("/api/users", { preHandler: createGuard }, async (req, reply) => {
|
|
const username = (req.body?.username ?? "").trim();
|
|
const password = req.body?.password ?? "";
|
|
const roleId = (req.body?.roleId ?? "").trim();
|
|
if (!username || !roleId) {
|
|
return reply.code(400).send({ error: "username and roleId required" });
|
|
}
|
|
if (password.length < MIN_PASSWORD) {
|
|
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
|
}
|
|
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
|
|
return reply.code(400).send({ error: "unknown roleId" });
|
|
}
|
|
// No-escalation: can't create a user with a role broader than your own.
|
|
if (exceedsCaller(req.user.roleId, roleId)) {
|
|
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
|
|
}
|
|
if (db.select().from(users).where(eq(users.username, username)).get()) {
|
|
return reply.code(409).send({ error: "username already exists" });
|
|
}
|
|
const id = randomUUID();
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
db.insert(users).values({ id, username, passwordHash, roleId }).run();
|
|
const created = db.select().from(users).where(eq(users.id, id)).get()!;
|
|
return reply.code(201).send(publicUser(created));
|
|
});
|
|
|
|
// Update a user's username and/or role. Guarded against orphaning admin.
|
|
app.put<{ Params: { id: string }; Body: UpdateBody }>(
|
|
"/api/users/:id",
|
|
{ preHandler: updateGuard },
|
|
async (req, reply) => {
|
|
const id = req.params.id;
|
|
const existing = db.select().from(users).where(eq(users.id, id)).get();
|
|
if (!existing) return reply.code(404).send({ error: "user not found" });
|
|
// No-escalation: can't modify a user who already outranks you.
|
|
if (exceedsCaller(req.user.roleId, existing.roleId)) {
|
|
return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" });
|
|
}
|
|
|
|
const next: { username?: string; roleId?: string } = {};
|
|
if (req.body?.username != null) {
|
|
const username = req.body.username.trim();
|
|
if (!username) return reply.code(400).send({ error: "username cannot be empty" });
|
|
const clash = db.select().from(users).where(eq(users.username, username)).get();
|
|
if (clash && clash.id !== id) return reply.code(409).send({ error: "username already exists" });
|
|
next.username = username;
|
|
}
|
|
if (req.body?.roleId != null) {
|
|
const roleId = req.body.roleId.trim();
|
|
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
|
|
return reply.code(400).send({ error: "unknown roleId" });
|
|
}
|
|
// No-escalation: can't promote a user into a role broader than your own.
|
|
if (exceedsCaller(req.user.roleId, roleId)) {
|
|
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
|
|
}
|
|
// No-lockout: don't move the last admin off the admin role.
|
|
if (roleId !== ADMIN_ROLE_ID && isLastAdmin(id)) {
|
|
return reply.code(409).send({ error: "cannot change the role of the last admin" });
|
|
}
|
|
next.roleId = roleId;
|
|
}
|
|
if (Object.keys(next).length === 0) {
|
|
return reply.code(400).send({ error: "nothing to update" });
|
|
}
|
|
db.update(users).set(next).where(eq(users.id, id)).run();
|
|
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
|
},
|
|
);
|
|
|
|
// Reset a user's password (admin sets a new one; >= 8 chars).
|
|
app.put<{ Params: { id: string }; Body: PasswordBody }>(
|
|
"/api/users/:id/password",
|
|
{ preHandler: updateGuard },
|
|
async (req, reply) => {
|
|
const id = req.params.id;
|
|
const target = db.select().from(users).where(eq(users.id, id)).get();
|
|
if (!target) {
|
|
return reply.code(404).send({ error: "user not found" });
|
|
}
|
|
// No-escalation: can't reset the password of a user who outranks you
|
|
// (that would be account takeover of a more-privileged account).
|
|
if (exceedsCaller(req.user.roleId, target.roleId)) {
|
|
return reply.code(403).send({ error: "cannot reset the password of a user whose role exceeds your own" });
|
|
}
|
|
const password = req.body?.password ?? "";
|
|
if (password.length < MIN_PASSWORD) {
|
|
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
|
}
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
db.update(users).set({ passwordHash }).where(eq(users.id, id)).run();
|
|
return { ok: true };
|
|
},
|
|
);
|
|
|
|
// Delete a user. Refused if it's the last admin (no-lockout).
|
|
app.delete<{ Params: { id: string } }>(
|
|
"/api/users/:id",
|
|
{ preHandler: deleteGuard },
|
|
async (req, reply) => {
|
|
const id = req.params.id;
|
|
const target = db.select().from(users).where(eq(users.id, id)).get();
|
|
if (!target) {
|
|
return reply.code(404).send({ error: "user not found" });
|
|
}
|
|
// No-escalation: can't delete a user who outranks you.
|
|
if (exceedsCaller(req.user.roleId, target.roleId)) {
|
|
return reply.code(403).send({ error: "cannot delete a user whose role exceeds your own" });
|
|
}
|
|
if (isLastAdmin(id)) {
|
|
return reply.code(409).send({ error: "cannot delete the last admin" });
|
|
}
|
|
db.delete(users).where(eq(users.id, id)).run();
|
|
return { ok: true };
|
|
},
|
|
);
|
|
}
|