feat: RBAC middleware and role-based route guards (Phase 1) #89

Merged
groombook-engineer[bot] merged 2 commits from feat/rbac-middleware-gro-103 into main 2026-03-21 19:31:18 +00:00
5 changed files with 435 additions and 91 deletions
+45 -51
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono"; import { Hono } from "hono";
import type { JwtPayload } from "../middleware/auth.js"; import type { JwtPayload } from "../middleware/auth.js";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Mock data ─────────────────────────────────────────────────────────────── // ─── Mock data ───────────────────────────────────────────────────────────────
@@ -160,13 +161,29 @@ vi.mock("@groombook/db", () => {
// ─── App setup ─────────────────────────────────────────────────────────────── // ─── App setup ───────────────────────────────────────────────────────────────
const { impersonationRouter } = await import("../routes/impersonation.js"); const { impersonationRouter } = await import("../routes/impersonation.js");
const { requireRole } = await import("../middleware/rbac.js");
function createApp(sub: string) { /**
const app = new Hono<{ Variables: { jwtPayload: JwtPayload } }>(); * Build a test app. If staffRow is null the middleware simulates
* resolveStaffMiddleware returning 403 (staff not found). An optional
* roleGuard applies requireRole(...roles) before the router.
*/
function createApp(
staffRow: (typeof MANAGER_STAFF) | null,
roleGuard?: string[]
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
c.set("jwtPayload", { sub } as JwtPayload); if (!staffRow) {
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
}
c.set("jwtPayload", { sub: staffRow.oidcSub } as JwtPayload);
c.set("staff", staffRow as unknown as StaffRow);
await next(); await next();
}); });
if (roleGuard && roleGuard.length > 0) {
app.use("*", requireRole(...(roleGuard as Parameters<typeof requireRole>)) as never);
}
app.route("/impersonation", impersonationRouter); app.route("/impersonation", impersonationRouter);
return app; return app;
} }
@@ -187,9 +204,8 @@ beforeEach(() => resetMock());
describe("POST /impersonation/sessions", () => { describe("POST /impersonation/sessions", () => {
it("creates a session for a manager", async () => { it("creates a session for a manager", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF, ["manager"]);
selectQueue.push( selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[CLIENT], // client lookup [CLIENT], // client lookup
[], // expireTimedOutSessions active query [], // expireTimedOutSessions active query
[] // existing active check [] // existing active check
@@ -205,9 +221,8 @@ describe("POST /impersonation/sessions", () => {
expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true); expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true);
}); });
it("rejects non-managers", async () => { it("rejects non-managers via requireRole guard", async () => {
const app = createApp("oidc-groomer-sub"); const app = createApp(GROOMER_STAFF, ["manager"]);
selectQueue.push([GROOMER_STAFF]);
const res = await app.request( const res = await app.request(
"/impersonation/sessions", "/impersonation/sessions",
@@ -216,12 +231,11 @@ describe("POST /impersonation/sessions", () => {
expect(res.status).toBe(403); expect(res.status).toBe(403);
const body = await res.json(); const body = await res.json();
expect(body.error).toMatch(/only managers/i); expect(body.error).toMatch(/forbidden/i);
}); });
it("returns 403 when staff record not found", async () => { it("returns 403 when staff record not found", async () => {
const app = createApp("unknown-sub"); const app = createApp(null);
selectQueue.push([]);
const res = await app.request( const res = await app.request(
"/impersonation/sessions", "/impersonation/sessions",
@@ -232,9 +246,8 @@ describe("POST /impersonation/sessions", () => {
}); });
it("returns 404 when client not found", async () => { it("returns 404 when client not found", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF, ["manager"]);
selectQueue.push( selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[] // client not found [] // client not found
); );
@@ -247,10 +260,9 @@ describe("POST /impersonation/sessions", () => {
}); });
it("returns 409 when active session already exists", async () => { it("returns 409 when active session already exists", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF, ["manager"]);
const existing = makeSession(); const existing = makeSession();
selectQueue.push( selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[CLIENT], // client lookup [CLIENT], // client lookup
[], // expireTimedOutSessions [], // expireTimedOutSessions
[existing] // existing active session [existing] // existing active session
@@ -271,10 +283,9 @@ describe("POST /impersonation/sessions", () => {
describe("GET /impersonation/sessions/:id", () => { describe("GET /impersonation/sessions/:id", () => {
it("returns session for the owning staff member", async () => { it("returns session for the owning staff member", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession(); const session = makeSession();
selectQueue.push( selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session] // session lookup [session] // session lookup
); );
@@ -283,10 +294,9 @@ describe("GET /impersonation/sessions/:id", () => {
}); });
it("returns 403 for a different staff member", async () => { it("returns 403 for a different staff member", async () => {
const app = createApp("oidc-groomer-sub"); const app = createApp(GROOMER_STAFF);
const session = makeSession(); // owned by manager const session = makeSession(); // owned by manager
selectQueue.push( selectQueue.push(
[GROOMER_STAFF], // resolveStaff
[session] // session lookup [session] // session lookup
); );
@@ -297,9 +307,8 @@ describe("GET /impersonation/sessions/:id", () => {
}); });
it("returns 404 for nonexistent session", async () => { it("returns 404 for nonexistent session", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
selectQueue.push( selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[] // no session [] // no session
); );
@@ -308,10 +317,9 @@ describe("GET /impersonation/sessions/:id", () => {
}); });
it("auto-expires a timed-out session", async () => { it("auto-expires a timed-out session", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() }); const session = makeSession({ expiresAt: pastDate() });
selectQueue.push( selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session] // session lookup [session] // session lookup
); );
@@ -329,10 +337,9 @@ describe("GET /impersonation/sessions/:id", () => {
describe("POST /impersonation/sessions/:id/extend", () => { describe("POST /impersonation/sessions/:id/extend", () => {
it("extends an active non-expired session", async () => { it("extends an active non-expired session", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession(); const session = makeSession();
selectQueue.push( selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session] // session lookup [session] // session lookup
); );
@@ -350,10 +357,9 @@ describe("POST /impersonation/sessions/:id/extend", () => {
}); });
it("returns 400 when extending a time-expired session", async () => { it("returns 400 when extending a time-expired session", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() }); const session = makeSession({ expiresAt: pastDate() });
selectQueue.push( selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session] // session lookup [session] // session lookup
); );
@@ -367,10 +373,9 @@ describe("POST /impersonation/sessions/:id/extend", () => {
}); });
it("returns 403 for non-owner", async () => { it("returns 403 for non-owner", async () => {
const app = createApp("oidc-groomer-sub"); const app = createApp(GROOMER_STAFF);
const session = makeSession(); const session = makeSession();
selectQueue.push( selectQueue.push(
[GROOMER_STAFF], // resolveStaff
[session] // owned by manager [session] // owned by manager
); );
@@ -382,10 +387,9 @@ describe("POST /impersonation/sessions/:id/extend", () => {
}); });
it("returns 400 for an ended session", async () => { it("returns 400 for an ended session", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession({ status: "ended" }); const session = makeSession({ status: "ended" });
selectQueue.push( selectQueue.push(
[MANAGER_STAFF],
[session] [session]
); );
@@ -403,10 +407,9 @@ describe("POST /impersonation/sessions/:id/extend", () => {
describe("POST /impersonation/sessions/:id/end", () => { describe("POST /impersonation/sessions/:id/end", () => {
it("ends an active non-expired session", async () => { it("ends an active non-expired session", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession(); const session = makeSession();
selectQueue.push( selectQueue.push(
[MANAGER_STAFF],
[session] [session]
); );
@@ -420,10 +423,9 @@ describe("POST /impersonation/sessions/:id/end", () => {
}); });
it("returns 400 when ending a time-expired session", async () => { it("returns 400 when ending a time-expired session", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() }); const session = makeSession({ expiresAt: pastDate() });
selectQueue.push( selectQueue.push(
[MANAGER_STAFF],
[session] [session]
); );
@@ -437,10 +439,9 @@ describe("POST /impersonation/sessions/:id/end", () => {
}); });
it("returns 403 for non-owner", async () => { it("returns 403 for non-owner", async () => {
const app = createApp("oidc-groomer-sub"); const app = createApp(GROOMER_STAFF);
const session = makeSession(); const session = makeSession();
selectQueue.push( selectQueue.push(
[GROOMER_STAFF],
[session] [session]
); );
@@ -458,10 +459,9 @@ describe("POST /impersonation/sessions/:id/log", () => {
const logBody = { action: "page_visit", pageVisited: "/dashboard" }; const logBody = { action: "page_visit", pageVisited: "/dashboard" };
it("logs an audit entry for the session owner", async () => { it("logs an audit entry for the session owner", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession(); const session = makeSession();
selectQueue.push( selectQueue.push(
[MANAGER_STAFF],
[session] [session]
); );
@@ -474,10 +474,9 @@ describe("POST /impersonation/sessions/:id/log", () => {
}); });
it("returns 403 for non-owner", async () => { it("returns 403 for non-owner", async () => {
const app = createApp("oidc-groomer-sub"); const app = createApp(GROOMER_STAFF);
const session = makeSession(); const session = makeSession();
selectQueue.push( selectQueue.push(
[GROOMER_STAFF],
[session] [session]
); );
@@ -491,10 +490,9 @@ describe("POST /impersonation/sessions/:id/log", () => {
}); });
it("returns 400 when session has expired by time", async () => { it("returns 400 when session has expired by time", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession({ expiresAt: pastDate() }); const session = makeSession({ expiresAt: pastDate() });
selectQueue.push( selectQueue.push(
[MANAGER_STAFF],
[session] [session]
); );
@@ -508,10 +506,9 @@ describe("POST /impersonation/sessions/:id/log", () => {
}); });
it("returns 400 for an ended session", async () => { it("returns 400 for an ended session", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession({ status: "ended" }); const session = makeSession({ status: "ended" });
selectQueue.push( selectQueue.push(
[MANAGER_STAFF],
[session] [session]
); );
@@ -529,11 +526,10 @@ describe("POST /impersonation/sessions/:id/log", () => {
describe("GET /impersonation/sessions/:id/audit-log", () => { describe("GET /impersonation/sessions/:id/audit-log", () => {
it("returns audit logs for the session owner", async () => { it("returns audit logs for the session owner", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
const session = makeSession(); const session = makeSession();
const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })]; const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })];
selectQueue.push( selectQueue.push(
[MANAGER_STAFF], // resolveStaff
[session], // session lookup [session], // session lookup
logs // audit logs query (where + orderBy chain) logs // audit logs query (where + orderBy chain)
); );
@@ -547,10 +543,9 @@ describe("GET /impersonation/sessions/:id/audit-log", () => {
}); });
it("returns 403 for non-owner", async () => { it("returns 403 for non-owner", async () => {
const app = createApp("oidc-groomer-sub"); const app = createApp(GROOMER_STAFF);
const session = makeSession(); const session = makeSession();
selectQueue.push( selectQueue.push(
[GROOMER_STAFF],
[session] [session]
); );
@@ -563,9 +558,8 @@ describe("GET /impersonation/sessions/:id/audit-log", () => {
}); });
it("returns 404 for nonexistent session", async () => { it("returns 404 for nonexistent session", async () => {
const app = createApp("oidc-manager-sub"); const app = createApp(MANAGER_STAFF);
selectQueue.push( selectQueue.push(
[MANAGER_STAFF],
[] []
); );
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Mock staff data ──────────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
role: "manager",
name: "Manager McManager",
email: "manager@example.com",
active: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const RECEPTIONIST: StaffRow = {
...MANAGER,
id: "staff-receptionist-id",
oidcSub: "oidc-receptionist-sub",
role: "receptionist",
name: "Receptionist Rita",
email: "receptionist@example.com",
};
const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
role: "groomer",
name: "Groomer Gary",
email: "groomer@example.com",
};
// ─── Mock DB ──────────────────────────────────────────────────────────────────
let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER;
vi.mock("@groombook/db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
get(target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: () => {
// dev mode fallback to first manager
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
if (staffLookupResult) yield staffLookupResult;
},
0: staffLookupResult,
length: staffLookupResult ? 1 : 0,
}),
}),
}),
}),
staff,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
function buildApp(
middleware: MiddlewareHandler<AppEnv>,
handler?: (c: Context<AppEnv>) => Response | Promise<Response>
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffLookupResult?.oidcSub ?? "unknown-sub" });
await next();
});
app.use("*", middleware);
const h = handler ?? ((c: Context<AppEnv>) => c.json({ ok: true }));
app.get("/test", h);
app.post("/test", h);
return app;
}
/** Build app with staff pre-set in context (skips resolveStaffMiddleware). */
function buildWithStaff(
staffRow: StaffRow,
guard: MiddlewareHandler<AppEnv>
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
c.set("staff", staffRow);
await next();
});
app.use("*", guard);
app.get("/test", (c) => c.json({ ok: true }));
app.post("/test", (c) => c.json({ ok: true }));
return app;
}
// ─── Import middleware ────────────────────────────────────────────────────────
const { resolveStaffMiddleware, requireRole } = await import(
"../middleware/rbac.js"
);
beforeEach(() => resetMocks());
afterEach(() => {
delete process.env.AUTH_DISABLED;
});
// ─── resolveStaffMiddleware tests ─────────────────────────────────────────────
describe("resolveStaffMiddleware", () => {
it("resolves staff from DB and sets it on context", async () => {
staffLookupResult = MANAGER;
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.id).toBe(MANAGER.id);
});
it("returns 403 when no staff record found for the OIDC sub", async () => {
staffLookupResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record/i);
});
it("dev mode: resolves staff by X-Dev-User-Id header", async () => {
process.env.AUTH_DISABLED = "true";
staffLookupResult = GROOMER;
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test", {
headers: { "X-Dev-User-Id": GROOMER.oidcSub! },
});
expect(res.status).toBe(200);
expect(capturedStaff!.role).toBe("groomer");
});
it("dev mode: falls back to first manager when no X-Dev-User-Id header", async () => {
process.env.AUTH_DISABLED = "true";
managerFallbackResult = MANAGER;
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff!.role).toBe("manager");
});
it("dev mode: returns 403 when no manager exists and no header provided", async () => {
process.env.AUTH_DISABLED = "true";
managerFallbackResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff records found/i);
});
});
// ─── requireRole tests ────────────────────────────────────────────────────────
describe("requireRole", () => {
it("allows access when staff role matches the only allowed role", async () => {
const app = buildWithStaff(MANAGER, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows access when staff role is one of multiple allowed roles", async () => {
const app = buildWithStaff(RECEPTIONIST, requireRole("manager", "receptionist"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("returns 403 for an unauthorized role", async () => {
const app = buildWithStaff(GROOMER, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/forbidden/i);
expect(body.error).toContain("groomer");
});
it("includes the role name in the 403 error message", async () => {
const app = buildWithStaff(RECEPTIONIST, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toContain("receptionist");
});
it("groomer is blocked from manager+receptionist-only routes", async () => {
const app = buildWithStaff(GROOMER, requireRole("manager", "receptionist"));
const res = await app.request("/test", { method: "POST" });
expect(res.status).toBe(403);
});
it("manager passes all-role checks", async () => {
const app = buildWithStaff(MANAGER, requireRole("manager", "receptionist", "groomer"));
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("returns 403 with JSON body (not plain text)", async () => {
const app = buildWithStaff(GROOMER, requireRole("manager"));
const res = await app.request("/test");
expect(res.status).toBe(403);
const contentType = res.headers.get("content-type") ?? "";
expect(contentType).toContain("application/json");
});
});
+29
View File
@@ -16,6 +16,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js"; import { settingsRouter } from "./routes/settings.js";
import { getDb, businessSettings } from "@groombook/db"; import { getDb, businessSettings } from "@groombook/db";
import { authMiddleware } from "./middleware/auth.js"; import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
import { devRouter } from "./routes/dev.js"; import { devRouter } from "./routes/dev.js";
import { startReminderScheduler } from "./services/reminders.js"; import { startReminderScheduler } from "./services/reminders.js";
@@ -57,6 +58,34 @@ app.get("/api/branding", async (c) => {
// Protected API routes // Protected API routes
const api = app.basePath("/api"); const api = app.basePath("/api");
api.use("*", authMiddleware); api.use("*", authMiddleware);
api.use("*", resolveStaffMiddleware);
// ── Role guards ────────────────────────────────────────────────────────────────
// Manager-only: staff, admin settings, reports, invoices, impersonation
api.use("/staff/*", requireRole("manager"));
api.use("/admin/*", requireRole("manager"));
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager"));
api.use("/impersonation/*", requireRole("manager"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs
api.use("/appointment-groups/*", requireRole("manager", "receptionist"));
api.use("/grooming-logs/*", requireRole("manager", "receptionist"));
// Clients, pets, appointments: all roles may read; only manager + receptionist may write
api.on(
["POST", "PUT", "PATCH", "DELETE"],
["/clients/*", "/pets/*", "/appointments/*"],
requireRole("manager", "receptionist")
);
// Services: all roles may read; only managers may write
api.on(
["POST", "PUT", "PATCH", "DELETE"],
"/services/*",
requireRole("manager")
);
// ──────────────────────────────────────────────────────────────────────────────
api.route("/clients", clientsRouter); api.route("/clients", clientsRouter);
api.route("/pets", petsRouter); api.route("/pets", petsRouter);
+101
View File
@@ -0,0 +1,101 @@
import type { MiddlewareHandler } from "hono";
import { eq, getDb, staff } from "@groombook/db";
import type { JwtPayload } from "./auth.js";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
export interface AppEnv {
Variables: {
jwtPayload: JwtPayload;
staff: StaffRow;
};
}
/**
* Resolves the authenticated staff record from the DB and stores it in context.
* Must be applied after authMiddleware on all protected routes.
*
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (treated
* as oidcSub), or falls back to the first manager in the DB.
*/
export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
c,
next
) => {
const db = getDb();
if (process.env.AUTH_DISABLED === "true") {
const devUserId = c.req.header("X-Dev-User-Id");
if (!devUserId) {
// No header — fall back to first manager
const [manager] = await db
.select()
.from(staff)
.where(eq(staff.role, "manager"))
.limit(1);
if (!manager) {
return c.json({ error: "Forbidden: no staff records found" }, 403);
}
c.set("staff", manager);
await next();
return;
}
// Treat X-Dev-User-Id as the oidcSub
const [row] = await db
.select()
.from(staff)
.where(eq(staff.oidcSub, devUserId));
if (!row) {
return c.json(
{ error: "Forbidden: no staff record found for X-Dev-User-Id" },
403
);
}
c.set("staff", row);
await next();
return;
}
const jwt = c.get("jwtPayload");
const [row] = await db
.select()
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
if (!row) {
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
}
c.set("staff", row);
await next();
};
/**
* Middleware factory that enforces one of the allowed roles.
* Must be applied after resolveStaffMiddleware.
*
* @example
* api.use("/staff/*", requireRole("manager"));
* api.use("/reports/*", requireRole("manager"));
*/
export function requireRole(
...allowedRoles: StaffRole[]
): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const staffRow = c.get("staff");
if (!staffRow) {
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
}
if (!(allowedRoles as string[]).includes(staffRow.role)) {
return c.json(
{
error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`,
},
403
);
}
await next();
};
}
+10 -40
View File
@@ -7,15 +7,12 @@ import {
getDb, getDb,
impersonationSessions, impersonationSessions,
impersonationAuditLogs, impersonationAuditLogs,
staff,
clients, clients,
desc, desc,
} from "@groombook/db"; } from "@groombook/db";
import type { JwtPayload } from "../middleware/auth.js"; import type { AppEnv } from "../middleware/rbac.js";
type Env = { Variables: { jwtPayload: JwtPayload } }; export const impersonationRouter = new Hono<AppEnv>();
export const impersonationRouter = new Hono<Env>();
const SESSION_TIMEOUT_MINUTES = 30; const SESSION_TIMEOUT_MINUTES = 30;
@@ -25,16 +22,6 @@ function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) {
return new Date(Date.now() + minutes * 60_000); return new Date(Date.now() + minutes * 60_000);
} }
/** Resolve the staff row for the authenticated OIDC subject. */
async function resolveStaff(sub: string) {
const db = getDb();
const [row] = await db
.select()
.from(staff)
.where(eq(staff.oidcSub, sub));
return row ?? null;
}
/** Expire any timed-out active sessions for a given staff member. */ /** Expire any timed-out active sessions for a given staff member. */
async function expireTimedOutSessions(staffId: string) { async function expireTimedOutSessions(staffId: string) {
const db = getDb(); const db = getDb();
@@ -76,7 +63,8 @@ async function checkAndExpireSession(
return true; return true;
} }
// ─── POST / — Start a new impersonation session ───────────────────────────── // ─── POST /sessions — Start a new impersonation session ─────────────────────
// requireRole("manager") is enforced by index.ts middleware on /impersonation/*
const startSessionSchema = z.object({ const startSessionSchema = z.object({
clientId: z.string().uuid(), clientId: z.string().uuid(),
@@ -88,16 +76,9 @@ impersonationRouter.post(
zValidator("json", startSessionSchema), zValidator("json", startSessionSchema),
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload; const staffRow = c.get("staff");
const body = c.req.valid("json"); const body = c.req.valid("json");
// Resolve authenticated staff
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
if (staffRow.role !== "manager") {
return c.json({ error: "Only managers can impersonate clients" }, 403);
}
// Verify client exists // Verify client exists
const [client] = await db const [client] = await db
.select() .select()
@@ -150,9 +131,7 @@ impersonationRouter.post(
impersonationRouter.get("/sessions/:id", async (c) => { impersonationRouter.get("/sessions/:id", async (c) => {
const db = getDb(); const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload; const staffRow = c.get("staff");
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const [session] = await db const [session] = await db
.select() .select()
@@ -176,9 +155,7 @@ impersonationRouter.get("/sessions/:id", async (c) => {
impersonationRouter.post("/sessions/:id/extend", async (c) => { impersonationRouter.post("/sessions/:id/extend", async (c) => {
const db = getDb(); const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload; const staffRow = c.get("staff");
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const [session] = await db const [session] = await db
.select() .select()
@@ -217,9 +194,7 @@ impersonationRouter.post("/sessions/:id/extend", async (c) => {
impersonationRouter.post("/sessions/:id/end", async (c) => { impersonationRouter.post("/sessions/:id/end", async (c) => {
const db = getDb(); const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload; const staffRow = c.get("staff");
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const [session] = await db const [session] = await db
.select() .select()
@@ -266,12 +241,9 @@ impersonationRouter.post(
zValidator("json", logEntrySchema), zValidator("json", logEntrySchema),
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload; const staffRow = c.get("staff");
const body = c.req.valid("json"); const body = c.req.valid("json");
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const [session] = await db const [session] = await db
.select() .select()
.from(impersonationSessions) .from(impersonationSessions)
@@ -307,9 +279,7 @@ impersonationRouter.post(
impersonationRouter.get("/sessions/:id/audit-log", async (c) => { impersonationRouter.get("/sessions/:id/audit-log", async (c) => {
const db = getDb(); const db = getDb();
const jwt = c.get("jwtPayload") as JwtPayload; const staffRow = c.get("staff");
const staffRow = await resolveStaff(jwt.sub);
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
const [session] = await db const [session] = await db
.select() .select()