From 93a9ae44618dc6fe1bda43b3852491766ddea415 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sat, 21 Mar 2026 15:50:45 +0000 Subject: [PATCH] feat: add RBAC middleware with role-based route guards (GRO-103) - New `apps/api/src/middleware/rbac.ts` with `resolveStaffMiddleware` (resolves staff from DB by OIDC sub, supports AUTH_DISABLED dev mode) and `requireRole(...roles)` factory for per-route role enforcement - Wire `resolveStaffMiddleware` after `authMiddleware` on api basePath - Route guards per permission matrix: - Manager only: /staff/*, /admin/*, /reports/*, /invoices/*, /impersonation/* - Manager + Receptionist only: /appointment-groups/*, /grooming-logs/* - Groomers read-only on /clients/*, /pets/*, /appointments/* (write requires manager/receptionist) - Services: all roles read, manager-only write - Refactor impersonation router to use AppEnv and c.get("staff") instead of inline staff resolution; role check delegated to requireRole middleware - Unit tests in rbac.test.ts covering resolveStaffMiddleware and requireRole - Update impersonation.test.ts to inject staff directly via context Closes #88 (Phase 1) Co-Authored-By: Paperclip --- apps/api/src/__tests__/impersonation.test.ts | 96 ++++--- apps/api/src/__tests__/rbac.test.ts | 249 +++++++++++++++++++ apps/api/src/index.ts | 29 +++ apps/api/src/middleware/rbac.ts | 101 ++++++++ apps/api/src/routes/impersonation.ts | 50 +--- 5 files changed, 434 insertions(+), 91 deletions(-) create mode 100644 apps/api/src/__tests__/rbac.test.ts create mode 100644 apps/api/src/middleware/rbac.ts diff --git a/apps/api/src/__tests__/impersonation.test.ts b/apps/api/src/__tests__/impersonation.test.ts index 8eb6217..a76a655 100644 --- a/apps/api/src/__tests__/impersonation.test.ts +++ b/apps/api/src/__tests__/impersonation.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; import type { JwtPayload } from "../middleware/auth.js"; +import type { AppEnv, StaffRow } from "../middleware/rbac.js"; // ─── Mock data ─────────────────────────────────────────────────────────────── @@ -160,13 +161,29 @@ vi.mock("@groombook/db", () => { // ─── App setup ─────────────────────────────────────────────────────────────── 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(); 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(); }); + if (roleGuard && roleGuard.length > 0) { + app.use("*", requireRole(...(roleGuard as Parameters)) as never); + } app.route("/impersonation", impersonationRouter); return app; } @@ -187,9 +204,8 @@ beforeEach(() => resetMock()); describe("POST /impersonation/sessions", () => { it("creates a session for a manager", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF, ["manager"]); selectQueue.push( - [MANAGER_STAFF], // resolveStaff [CLIENT], // client lookup [], // expireTimedOutSessions active query [] // existing active check @@ -205,9 +221,8 @@ describe("POST /impersonation/sessions", () => { expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true); }); - it("rejects non-managers", async () => { - const app = createApp("oidc-groomer-sub"); - selectQueue.push([GROOMER_STAFF]); + it("rejects non-managers via requireRole guard", async () => { + const app = createApp(GROOMER_STAFF, ["manager"]); const res = await app.request( "/impersonation/sessions", @@ -216,12 +231,11 @@ describe("POST /impersonation/sessions", () => { expect(res.status).toBe(403); 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 () => { - const app = createApp("unknown-sub"); - selectQueue.push([]); + const app = createApp(null); const res = await app.request( "/impersonation/sessions", @@ -232,9 +246,8 @@ describe("POST /impersonation/sessions", () => { }); it("returns 404 when client not found", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF, ["manager"]); selectQueue.push( - [MANAGER_STAFF], // resolveStaff [] // client not found ); @@ -247,10 +260,9 @@ describe("POST /impersonation/sessions", () => { }); it("returns 409 when active session already exists", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF, ["manager"]); const existing = makeSession(); selectQueue.push( - [MANAGER_STAFF], // resolveStaff [CLIENT], // client lookup [], // expireTimedOutSessions [existing] // existing active session @@ -271,10 +283,9 @@ describe("POST /impersonation/sessions", () => { describe("GET /impersonation/sessions/:id", () => { it("returns session for the owning staff member", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); const session = makeSession(); selectQueue.push( - [MANAGER_STAFF], // resolveStaff [session] // session lookup ); @@ -283,10 +294,9 @@ describe("GET /impersonation/sessions/:id", () => { }); 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 selectQueue.push( - [GROOMER_STAFF], // resolveStaff [session] // session lookup ); @@ -297,9 +307,8 @@ describe("GET /impersonation/sessions/:id", () => { }); it("returns 404 for nonexistent session", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); selectQueue.push( - [MANAGER_STAFF], // resolveStaff [] // no session ); @@ -308,10 +317,9 @@ describe("GET /impersonation/sessions/:id", () => { }); it("auto-expires a timed-out session", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); const session = makeSession({ expiresAt: pastDate() }); selectQueue.push( - [MANAGER_STAFF], // resolveStaff [session] // session lookup ); @@ -329,10 +337,9 @@ describe("GET /impersonation/sessions/:id", () => { describe("POST /impersonation/sessions/:id/extend", () => { it("extends an active non-expired session", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); const session = makeSession(); selectQueue.push( - [MANAGER_STAFF], // resolveStaff [session] // session lookup ); @@ -350,10 +357,9 @@ describe("POST /impersonation/sessions/:id/extend", () => { }); 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() }); selectQueue.push( - [MANAGER_STAFF], // resolveStaff [session] // session lookup ); @@ -367,10 +373,9 @@ describe("POST /impersonation/sessions/:id/extend", () => { }); it("returns 403 for non-owner", async () => { - const app = createApp("oidc-groomer-sub"); + const app = createApp(GROOMER_STAFF); const session = makeSession(); selectQueue.push( - [GROOMER_STAFF], // resolveStaff [session] // owned by manager ); @@ -382,10 +387,9 @@ describe("POST /impersonation/sessions/:id/extend", () => { }); it("returns 400 for an ended session", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); const session = makeSession({ status: "ended" }); selectQueue.push( - [MANAGER_STAFF], [session] ); @@ -403,10 +407,9 @@ describe("POST /impersonation/sessions/:id/extend", () => { describe("POST /impersonation/sessions/:id/end", () => { it("ends an active non-expired session", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); const session = makeSession(); selectQueue.push( - [MANAGER_STAFF], [session] ); @@ -420,10 +423,9 @@ describe("POST /impersonation/sessions/:id/end", () => { }); 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() }); selectQueue.push( - [MANAGER_STAFF], [session] ); @@ -437,10 +439,9 @@ describe("POST /impersonation/sessions/:id/end", () => { }); it("returns 403 for non-owner", async () => { - const app = createApp("oidc-groomer-sub"); + const app = createApp(GROOMER_STAFF); const session = makeSession(); selectQueue.push( - [GROOMER_STAFF], [session] ); @@ -458,10 +459,9 @@ describe("POST /impersonation/sessions/:id/log", () => { const logBody = { action: "page_visit", pageVisited: "/dashboard" }; it("logs an audit entry for the session owner", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); const session = makeSession(); selectQueue.push( - [MANAGER_STAFF], [session] ); @@ -474,10 +474,9 @@ describe("POST /impersonation/sessions/:id/log", () => { }); it("returns 403 for non-owner", async () => { - const app = createApp("oidc-groomer-sub"); + const app = createApp(GROOMER_STAFF); const session = makeSession(); selectQueue.push( - [GROOMER_STAFF], [session] ); @@ -491,10 +490,9 @@ describe("POST /impersonation/sessions/:id/log", () => { }); 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() }); selectQueue.push( - [MANAGER_STAFF], [session] ); @@ -508,10 +506,9 @@ describe("POST /impersonation/sessions/:id/log", () => { }); it("returns 400 for an ended session", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); const session = makeSession({ status: "ended" }); selectQueue.push( - [MANAGER_STAFF], [session] ); @@ -529,11 +526,10 @@ describe("POST /impersonation/sessions/:id/log", () => { describe("GET /impersonation/sessions/:id/audit-log", () => { it("returns audit logs for the session owner", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); const session = makeSession(); const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })]; selectQueue.push( - [MANAGER_STAFF], // resolveStaff [session], // session lookup 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 () => { - const app = createApp("oidc-groomer-sub"); + const app = createApp(GROOMER_STAFF); const session = makeSession(); selectQueue.push( - [GROOMER_STAFF], [session] ); @@ -563,9 +558,8 @@ describe("GET /impersonation/sessions/:id/audit-log", () => { }); it("returns 404 for nonexistent session", async () => { - const app = createApp("oidc-manager-sub"); + const app = createApp(MANAGER_STAFF); selectQueue.push( - [MANAGER_STAFF], [] ); diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts new file mode 100644 index 0000000..d1de9f7 --- /dev/null +++ b/apps/api/src/__tests__/rbac.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv } from "../middleware/rbac.js"; + +// ─── Mock staff data ────────────────────────────────────────────────────────── + +const MANAGER = { + id: "staff-manager-id", + oidcSub: "oidc-manager-sub", + role: "manager" as const, + name: "Manager McManager", + email: "manager@example.com", + active: true, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const RECEPTIONIST = { + ...MANAGER, + id: "staff-receptionist-id", + oidcSub: "oidc-receptionist-sub", + role: "receptionist" as const, + name: "Receptionist Rita", + email: "receptionist@example.com", +}; + +const GROOMER = { + ...MANAGER, + id: "staff-groomer-id", + oidcSub: "oidc-groomer-sub", + role: "groomer" as const, + name: "Groomer Gary", + email: "groomer@example.com", +}; + +// ─── Mock DB ────────────────────────────────────────────────────────────────── + +let staffLookupResult: typeof MANAGER | null = null; +let managerFallbackResult: typeof MANAGER | 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] : []; + }, + // direct .where() termination (oidcSub lookup) + then: undefined, + [Symbol.iterator]: function* () { + if (staffLookupResult) yield staffLookupResult; + }, + 0: staffLookupResult, + length: staffLookupResult ? 1 : 0, + }), + }), + }), + }), + staff, + eq: vi.fn((_col, _val) => ({ col: _col, val: _val })), + }; +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function resetMocks() { + staffLookupResult = null; + managerFallbackResult = MANAGER; +} + +/** Build a minimal Hono app with jwtPayload already set, then apply the given middleware. */ +function buildApp( + middleware: Parameters["use"]>[1], + handler?: (c: Parameters["get"]>[1]>[0]) => Response | Promise +) { + const app = new Hono(); + // Inject jwtPayload as if authMiddleware already ran + app.use("*", async (c, next) => { + c.set("jwtPayload", { sub: staffLookupResult?.oidcSub ?? "unknown-sub" }); + await next(); + }); + app.use("*", middleware as never); + app.get("/test", handler ?? ((c) => c.json({ ok: true }))); + app.post("/test", handler ?? ((c) => c.json({ ok: true }))); + return app; +} + +// ─── resolveStaffMiddleware tests ───────────────────────────────────────────── + +const { resolveStaffMiddleware, requireRole } = await import( + "../middleware/rbac.js" +); + +beforeEach(() => resetMocks()); + +afterEach(() => { + delete process.env.AUTH_DISABLED; +}); + +describe("resolveStaffMiddleware", () => { + it("resolves staff from DB and sets it on context", async () => { + staffLookupResult = MANAGER; + let capturedStaff: unknown = 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).toBeTruthy(); + expect((capturedStaff as typeof MANAGER).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: unknown = 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 as typeof GROOMER).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: unknown = 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 as typeof MANAGER).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", () => { + /** Build app with staff pre-set in context (skips resolveStaffMiddleware). */ + function buildWithStaff( + staffRow: typeof MANAGER, + guard: ReturnType + ) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("jwtPayload", { sub: staffRow.oidcSub }); + c.set("staff", staffRow as never); + await next(); + }); + app.use("*", guard as never); + app.get("/test", (c) => c.json({ ok: true })); + app.post("/test", (c) => c.json({ ok: true })); + return app; + } + + 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-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"); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b4866d1..3454a83 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -16,6 +16,7 @@ import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; import { getDb, businessSettings } from "@groombook/db"; import { authMiddleware } from "./middleware/auth.js"; +import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js"; import { devRouter } from "./routes/dev.js"; import { startReminderScheduler } from "./services/reminders.js"; @@ -57,6 +58,34 @@ app.get("/api/branding", async (c) => { // Protected API routes const api = app.basePath("/api"); 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("/pets", petsRouter); diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts new file mode 100644 index 0000000..24a6753 --- /dev/null +++ b/apps/api/src/middleware/rbac.ts @@ -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 = 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 { + 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(); + }; +} diff --git a/apps/api/src/routes/impersonation.ts b/apps/api/src/routes/impersonation.ts index d7a627e..00feb9d 100644 --- a/apps/api/src/routes/impersonation.ts +++ b/apps/api/src/routes/impersonation.ts @@ -7,15 +7,12 @@ import { getDb, impersonationSessions, impersonationAuditLogs, - staff, clients, desc, } 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(); +export const impersonationRouter = new Hono(); const SESSION_TIMEOUT_MINUTES = 30; @@ -25,16 +22,6 @@ function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) { 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. */ async function expireTimedOutSessions(staffId: string) { const db = getDb(); @@ -76,7 +63,8 @@ async function checkAndExpireSession( 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({ clientId: z.string().uuid(), @@ -88,16 +76,9 @@ impersonationRouter.post( zValidator("json", startSessionSchema), async (c) => { const db = getDb(); - const jwt = c.get("jwtPayload") as JwtPayload; + const staffRow = c.get("staff"); 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 const [client] = await db .select() @@ -150,9 +131,7 @@ impersonationRouter.post( impersonationRouter.get("/sessions/:id", async (c) => { const db = getDb(); - const jwt = c.get("jwtPayload") as JwtPayload; - const staffRow = await resolveStaff(jwt.sub); - if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + const staffRow = c.get("staff"); const [session] = await db .select() @@ -176,9 +155,7 @@ impersonationRouter.get("/sessions/:id", async (c) => { impersonationRouter.post("/sessions/:id/extend", async (c) => { const db = getDb(); - const jwt = c.get("jwtPayload") as JwtPayload; - const staffRow = await resolveStaff(jwt.sub); - if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + const staffRow = c.get("staff"); const [session] = await db .select() @@ -217,9 +194,7 @@ impersonationRouter.post("/sessions/:id/extend", async (c) => { impersonationRouter.post("/sessions/:id/end", async (c) => { const db = getDb(); - const jwt = c.get("jwtPayload") as JwtPayload; - const staffRow = await resolveStaff(jwt.sub); - if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + const staffRow = c.get("staff"); const [session] = await db .select() @@ -266,12 +241,9 @@ impersonationRouter.post( zValidator("json", logEntrySchema), async (c) => { const db = getDb(); - const jwt = c.get("jwtPayload") as JwtPayload; + const staffRow = c.get("staff"); 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 .select() .from(impersonationSessions) @@ -307,9 +279,7 @@ impersonationRouter.post( impersonationRouter.get("/sessions/:id/audit-log", async (c) => { const db = getDb(); - const jwt = c.get("jwtPayload") as JwtPayload; - const staffRow = await resolveStaff(jwt.sub); - if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + const staffRow = c.get("staff"); const [session] = await db .select()