From eeda5099be39a1e198c407c233309e7eeac14760 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 26 Mar 2026 21:57:09 +0000 Subject: [PATCH] feat(api): RBAC Phase 2 - row-level data scoping for groomer role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter query results at the route handler level when the authenticated staff role is 'groomer': - GET /api/appointments: WHERE staffId = - GET /api/appointments/:id: 403 if not assigned to groomer - GET /api/clients: clients with ≥1 appointment for this groomer - GET /api/clients/:id: 403 if no appointment linkage - GET /api/pets: pets owned by groomer-linked clients - GET /api/pets/:petId: 403 if no appointment linkage Managers and receptionists: no change. Co-Authored-By: Paperclip --- apps/api/src/routes/appointments.ts | 16 ++++++++- apps/api/src/routes/clients.ts | 52 ++++++++++++++++++++++++++--- apps/api/src/routes/pets.ts | 49 +++++++++++++++++++++++++-- packages/db/src/index.ts | 2 +- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index edecf8d..5755233 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -18,10 +18,11 @@ import { services, staff, } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; import { buildConfirmationEmail, sendEmail } from "../services/email.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; -export const appointmentsRouter = new Hono(); +export const appointmentsRouter = new Hono(); const createAppointmentSchema = z.object({ clientId: z.string().uuid(), @@ -66,6 +67,7 @@ const updateAppointmentSchema = z.object({ // List appointments, optionally filtered by date range or staffId appointmentsRouter.get("/", async (c) => { const db = getDb(); + const currentStaff = c.get("staff"); const from = c.req.query("from"); const to = c.req.query("to"); const staffId = c.req.query("staffId"); @@ -75,6 +77,11 @@ appointmentsRouter.get("/", async (c) => { if (to) conditions.push(lte(appointments.startTime, new Date(to))); if (staffId) conditions.push(eq(appointments.staffId, staffId)); + // Row-level scoping: groomers see only their own appointments + if (currentStaff.role === "groomer") { + conditions.push(eq(appointments.staffId, currentStaff.id)); + } + const rows = conditions.length > 0 ? await db @@ -92,11 +99,18 @@ appointmentsRouter.get("/", async (c) => { appointmentsRouter.get("/:id", async (c) => { const db = getDb(); + const currentStaff = c.get("staff"); const [row] = await db .select() .from(appointments) .where(eq(appointments.id, c.req.param("id"))); if (!row) return c.json({ error: "Not found" }, 404); + + // Row-level scoping: groomers can only view their own appointments + if (currentStaff.role === "groomer" && row.staffId !== currentStaff.id) { + return c.json({ error: "Forbidden" }, 403); + } + return c.json(row); }); diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts index 90313a2..497a033 100644 --- a/apps/api/src/routes/clients.ts +++ b/apps/api/src/routes/clients.ts @@ -1,9 +1,10 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; -import { eq, getDb, clients } from "@groombook/db"; +import { and, eq, inArray, getDb, clients, appointments } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; -export const clientsRouter = new Hono(); +export const clientsRouter = new Hono(); const createClientSchema = z.object({ name: z.string().min(1).max(200), @@ -15,9 +16,33 @@ const createClientSchema = z.object({ // List clients — defaults to active only, ?includeDisabled=true shows all +// Groomers see only clients with at least one appointment assigned to them clientsRouter.get("/", async (c) => { const db = getDb(); + const currentStaff = c.get("staff"); const includeDisabled = c.req.query("includeDisabled") === "true"; + + // Row-level scoping: groomers see only clients with ≥1 appointment for them + if (currentStaff.role === "groomer") { + const groomerAppointments = await db + .select({ clientId: appointments.clientId }) + .from(appointments) + .where(eq(appointments.staffId, currentStaff.id)); + + const clientIds = [...new Set(groomerAppointments.map((a) => a.clientId))]; + if (clientIds.length === 0) return c.json([]); + + const conditions = [inArray(clients.id, clientIds)]; + if (!includeDisabled) conditions.push(eq(clients.status, "active")); + + const rows = await db + .select() + .from(clients) + .where(and(...conditions)) + .orderBy(clients.name); + return c.json(rows); + } + const query = includeDisabled ? db.select().from(clients).orderBy(clients.name) : db.select().from(clients).where(eq(clients.status, "active")).orderBy(clients.name); @@ -25,14 +50,33 @@ clientsRouter.get("/", async (c) => { return c.json(rows); }); -// Get a single client +// Get a single client — groomers get 403 if no appointment links them to this client clientsRouter.get("/:id", async (c) => { const db = getDb(); + const currentStaff = c.get("staff"); + const clientId = c.req.param("id"); + const [row] = await db .select() .from(clients) - .where(eq(clients.id, c.req.param("id"))); + .where(eq(clients.id, clientId)); if (!row) return c.json({ error: "Not found" }, 404); + + // Row-level scoping: groomers can only see clients linked via an appointment + if (currentStaff.role === "groomer") { + const linked = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, clientId), + eq(appointments.staffId, currentStaff.id) + ) + ) + .limit(1); + if (linked.length === 0) return c.json({ error: "Forbidden" }, 403); + } + return c.json(row); }); diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index 6e2e8e6..f726467 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; -import { eq, getDb, pets } from "@groombook/db"; +import { and, eq, inArray, getDb, pets, appointments } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; import { getPresignedUploadUrl, @@ -30,7 +30,33 @@ const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); petsRouter.get("/", async (c) => { const db = getDb(); + const currentStaff = c.get("staff"); const clientId = c.req.query("clientId"); + + // Row-level scoping: groomers see only pets owned by their linked clients + if (currentStaff.role === "groomer") { + const groomerAppointments = await db + .select({ clientId: appointments.clientId }) + .from(appointments) + .where(eq(appointments.staffId, currentStaff.id)); + + const clientIds = [...new Set(groomerAppointments.map((a) => a.clientId))]; + if (clientIds.length === 0) return c.json([]); + + // If clientId is explicitly specified, verify it belongs to the groomer's scope + if (clientId) { + if (!clientIds.includes(clientId)) return c.json([]); + const rows = await db.select().from(pets).where(eq(pets.clientId, clientId)); + return c.json(rows); + } + + const rows = await db + .select() + .from(pets) + .where(inArray(pets.clientId, clientIds)); + return c.json(rows); + } + const query = db.select().from(pets); if (clientId) { const rows = await query.where(eq(pets.clientId, clientId)); @@ -42,11 +68,30 @@ petsRouter.get("/", async (c) => { petsRouter.get("/:id", async (c) => { const db = getDb(); + const currentStaff = c.get("staff"); + const petId = c.req.param("id"); + const [row] = await db .select() .from(pets) - .where(eq(pets.id, c.req.param("id"))); + .where(eq(pets.id, petId)); if (!row) return c.json({ error: "Not found" }, 404); + + // Row-level scoping: groomers can only see pets linked via an appointment + if (currentStaff.role === "groomer") { + const linked = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, row.clientId), + eq(appointments.staffId, currentStaff.id) + ) + ) + .limit(1); + if (linked.length === 0) return c.json({ error: "Forbidden" }, 403); + } + return c.json(row); }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 0ba0d5e..faf05c6 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,7 +3,7 @@ import postgres from "postgres"; import * as schema from "./schema.js"; export * from "./schema.js"; -export { and, asc, desc, eq, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm"; +export { and, asc, desc, eq, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm"; let _db: ReturnType | null = null;