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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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<AppEnv>();
|
||||
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<typeof requireRole>)) 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],
|
||||
[]
|
||||
);
|
||||
|
||||
|
||||
@@ -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<Hono<AppEnv>["use"]>[1],
|
||||
handler?: (c: Parameters<Parameters<Hono<AppEnv>["get"]>[1]>[0]) => Response | Promise<Response>
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
// 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<typeof requireRole>
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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<Env>();
|
||||
export const impersonationRouter = new Hono<AppEnv>();
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user