feat(gro-48): row-level data scoping for groomer role (RBAC Phase 2)
Filter query results at the route handler level when staff role is groomer: - GET /api/appointments: WHERE staffId = groomer OR batherStaffId = groomer - GET /api/appointments/🆔 403 if not assigned to groomer (as staff or bather) - GET /api/clients: Clients with ≥1 appointment for this groomer (via exists subquery) - GET /api/clients/🆔 403 if no appointment linkage - GET /api/pets: Pets owned by groomer-linked clients (via exists subquery) - GET /api/pets/:petId: 403 if no appointment linkage Managers and receptionists: no change. Added exists to @groombook/db exports (was missing from re-export). Added groomerIsolation unit tests for role guard and filter logic. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Groomer Isolation Tests
|
||||||
|
*
|
||||||
|
* Validates row-level data scoping for the groomer role.
|
||||||
|
*
|
||||||
|
* The role guard tests verify the core groomer identification logic.
|
||||||
|
* Integration tests with the real database validate the full filter behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { StaffRow } from "../middleware/rbac.js";
|
||||||
|
|
||||||
|
// ─── Mock staff ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 GROOMER: StaffRow = {
|
||||||
|
...MANAGER,
|
||||||
|
id: "staff-groomer-id",
|
||||||
|
oidcSub: "oidc-groomer-sub",
|
||||||
|
role: "groomer",
|
||||||
|
name: "Groomer Gary",
|
||||||
|
email: "groomer@example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECEPTIONIST: StaffRow = {
|
||||||
|
...MANAGER,
|
||||||
|
id: "staff-receptionist-id",
|
||||||
|
oidcSub: "oidc-receptionist-sub",
|
||||||
|
role: "receptionist",
|
||||||
|
name: "Receptionist Rita",
|
||||||
|
email: "receptionist@example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Role guard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The isGroomer guard (staffRow?.role === "groomer") is the foundation of
|
||||||
|
* all row-level filtering in appointments.ts, clients.ts, and pets.ts.
|
||||||
|
* These tests verify it handles all roles correctly.
|
||||||
|
*/
|
||||||
|
describe("Groomer role guard", () => {
|
||||||
|
const isGroomer = (s: StaffRow | undefined) => s?.role === "groomer";
|
||||||
|
|
||||||
|
it("manager is not groomer", () => expect(isGroomer(MANAGER)).toBe(false));
|
||||||
|
it("receptionist is not groomer", () => expect(isGroomer(RECEPTIONIST)).toBe(false));
|
||||||
|
it("groomer is groomer", () => expect(isGroomer(GROOMER)).toBe(true));
|
||||||
|
|
||||||
|
/** Safe fallback when staff context is not set (e.g., missing auth middleware) */
|
||||||
|
it("undefined staff is not groomer", () => expect(isGroomer(undefined)).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Groomer filter data shapes ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These constants match the shape used in route handlers to validate
|
||||||
|
* the groomer filter conditions:
|
||||||
|
* or(eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id))
|
||||||
|
* This verifies the groomer can see appointments they own OR bathe.
|
||||||
|
*/
|
||||||
|
describe("Groomer appointment filter data", () => {
|
||||||
|
const GROOMER_APPT = { id: "appt-1", staffId: GROOMER.id, batherStaffId: null as string | null };
|
||||||
|
const BATHER_APPT = { id: "appt-2", staffId: MANAGER.id, batherStaffId: GROOMER.id };
|
||||||
|
const OTHER_APPT = { id: "appt-3", staffId: MANAGER.id, batherStaffId: null as string | null };
|
||||||
|
|
||||||
|
it("groomer appointment has groomer staffId", () => {
|
||||||
|
expect(GROOMER_APPT.staffId).toBe(GROOMER.id);
|
||||||
|
expect(GROOMER_APPT.batherStaffId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groomer can see appointment where they are the bather", () => {
|
||||||
|
expect(BATHER_APPT.batherStaffId).toBe(GROOMER.id);
|
||||||
|
expect(BATHER_APPT.staffId).toBe(MANAGER.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("other appointment is not assigned to groomer", () => {
|
||||||
|
expect(OTHER_APPT.staffId).toBe(MANAGER.id);
|
||||||
|
expect(OTHER_APPT.batherStaffId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filter: groomer sees only their appointments", () => {
|
||||||
|
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
|
||||||
|
const groomerView = all.filter(
|
||||||
|
(a) => a.staffId === GROOMER.id || a.batherStaffId === GROOMER.id
|
||||||
|
);
|
||||||
|
expect(groomerView).toHaveLength(2);
|
||||||
|
expect(groomerView.map((a) => a.id)).toEqual(["appt-1", "appt-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filter: manager sees all appointments", () => {
|
||||||
|
const all = [GROOMER_APPT, BATHER_APPT, OTHER_APPT];
|
||||||
|
expect(all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
lt,
|
lt,
|
||||||
lte,
|
lte,
|
||||||
ne,
|
ne,
|
||||||
|
or,
|
||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
pets,
|
pets,
|
||||||
@@ -20,8 +21,9 @@ import {
|
|||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const appointmentsRouter = new Hono();
|
export const appointmentsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createAppointmentSchema = z.object({
|
const createAppointmentSchema = z.object({
|
||||||
clientId: z.string().uuid(),
|
clientId: z.string().uuid(),
|
||||||
@@ -63,18 +65,31 @@ const updateAppointmentSchema = z.object({
|
|||||||
cascadeMode: z.enum(["this_only", "this_and_future", "all"]).optional(),
|
cascadeMode: z.enum(["this_only", "this_and_future", "all"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// List appointments, optionally filtered by date range or staffId
|
// List appointments, optionally filtered by date range or staffId.
|
||||||
|
// Groomers see only their own appointments (staffId or batherStaffId).
|
||||||
appointmentsRouter.get("/", async (c) => {
|
appointmentsRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const from = c.req.query("from");
|
const from = c.req.query("from");
|
||||||
const to = c.req.query("to");
|
const to = c.req.query("to");
|
||||||
const staffId = c.req.query("staffId");
|
const staffId = c.req.query("staffId");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow.role === "groomer";
|
||||||
|
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
if (from) conditions.push(gte(appointments.startTime, new Date(from)));
|
if (from) conditions.push(gte(appointments.startTime, new Date(from)));
|
||||||
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
||||||
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
||||||
|
|
||||||
|
// Groomer: restrict to their own appointments (as groomer or bather)
|
||||||
|
if (isGroomer) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rows =
|
const rows =
|
||||||
conditions.length > 0
|
conditions.length > 0
|
||||||
? await db
|
? await db
|
||||||
@@ -92,11 +107,17 @@ appointmentsRouter.get("/", async (c) => {
|
|||||||
|
|
||||||
appointmentsRouter.get("/:id", async (c) => {
|
appointmentsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow.role === "groomer";
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(eq(appointments.id, c.req.param("id")));
|
.where(eq(appointments.id, c.req.param("id")));
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
// Groomer: 403 if not assigned as groomer or bather
|
||||||
|
if (isGroomer && row.staffId !== staffRow.id && row.batherStaffId !== staffRow.id) {
|
||||||
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, getDb, clients } from "@groombook/db";
|
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const clientsRouter = new Hono();
|
export const clientsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -14,25 +15,72 @@ const createClientSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// List clients — defaults to active only, ?includeDisabled=true shows all
|
// List clients — defaults to active only, ?includeDisabled=true shows all.
|
||||||
|
// Groomers see only clients with ≥1 appointment assigned to them.
|
||||||
clientsRouter.get("/", async (c) => {
|
clientsRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const includeDisabled = c.req.query("includeDisabled") === "true";
|
const includeDisabled = c.req.query("includeDisabled") === "true";
|
||||||
const query = includeDisabled
|
const staffRow = c.get("staff");
|
||||||
? db.select().from(clients).orderBy(clients.name)
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
: db.select().from(clients).where(eq(clients.status, "active")).orderBy(clients.name);
|
|
||||||
const rows = await query;
|
// Groomer: subquery for clients with an appointment for this groomer
|
||||||
|
const groomerApptFilter = isGroomer
|
||||||
|
? exists(
|
||||||
|
db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.clientId, clients.id),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (!includeDisabled) conditions.push(eq(clients.status, "active"));
|
||||||
|
if (groomerApptFilter) conditions.push(groomerApptFilter);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(clients.name);
|
||||||
return c.json(rows);
|
return c.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get a single client
|
// Get a single client
|
||||||
clientsRouter.get("/:id", async (c) => {
|
clientsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const clientId = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.id, c.req.param("id")));
|
.where(eq(clients.id, clientId));
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
// Groomer: 403 if no appointment linkage to this client
|
||||||
|
if (isGroomer) {
|
||||||
|
const [linkage] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.clientId, clientId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, getDb, pets } from "@groombook/db";
|
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -28,25 +28,70 @@ const createPetSchema = z.object({
|
|||||||
|
|
||||||
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
||||||
|
|
||||||
|
// List pets, optionally filtered by clientId.
|
||||||
|
// Groomers see only pets owned by clients with ≥1 appointment for this groomer.
|
||||||
petsRouter.get("/", async (c) => {
|
petsRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.req.query("clientId");
|
const clientId = c.req.query("clientId");
|
||||||
const query = db.select().from(pets);
|
const staffRow = c.get("staff");
|
||||||
if (clientId) {
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
const rows = await query.where(eq(pets.clientId, clientId));
|
|
||||||
return c.json(rows);
|
// Groomer: filter to pets whose client has an appointment for this groomer
|
||||||
}
|
const groomerClientFilter = isGroomer
|
||||||
const rows = await query;
|
? exists(
|
||||||
|
db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.clientId, pets.clientId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (clientId) conditions.push(eq(pets.clientId, clientId));
|
||||||
|
if (groomerClientFilter) conditions.push(groomerClientFilter);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pets)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
||||||
return c.json(rows);
|
return c.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
petsRouter.get("/:id", async (c) => {
|
petsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const petId = c.req.param("id");
|
||||||
|
const staffRow = c.get("staff");
|
||||||
|
const isGroomer = staffRow?.role === "groomer";
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(pets)
|
.from(pets)
|
||||||
.where(eq(pets.id, c.req.param("id")));
|
.where(eq(pets.id, petId));
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
// Groomer: 403 if no appointment linkage to this pet's client
|
||||||
|
if (isGroomer) {
|
||||||
|
const [linkage] = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.clientId, row.clientId),
|
||||||
|
or(
|
||||||
|
eq(appointments.staffId, staffRow.id),
|
||||||
|
eq(appointments.batherStaffId, staffRow.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!linkage) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import postgres from "postgres";
|
|||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
export * 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, exists, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user