fix(pets): port owner-bypass into deployed tree (GRO-2013)
The previous fix for GRO-2013 (customer cannot view own pet profile summary) landed in apps/api/src/routes/pets.ts, which is dead code in the Docker build path. The Dockerfile does COPY src/ + pnpm build from the repo root, so apps/api/ is never copied into the image and is not a pnpm-workspace member. Port the owner-bypass into the deployed-tree handler src/routes/pets.ts: - Add resolveImpersonationClientId(db, c) helper that reads the X-Impersonation-Session-Id header, validates the session is active and not expired, and returns its clientId (or null). - Gate the existing groomer 403 in GET /:id/profile-summary so an owner (session.clientId === pet.clientId) bypasses the appointment-linkage check. This mirrors the already-reviewed logic from apps/api/src/routes/pets.ts:318-364. - Cross-tenant access remains blocked: the bypass requires session.clientId === pet.clientId, and groomers with no portal session still 403 as before. Tests (src/__tests__/petProfileSummary.test.ts — new file, mirroring the dead-tree test pattern but pointing at the deployed handler): - Customer with valid active session for pet's client → 200 - Customer with no header → 403 - Customer with session for a different client → 403 - Customer with expired session → 403 - Customer with ended (status != active) session → 403 - Customer with unknown session id → 403 - Manager does not need the impersonation header (regression) - Groomer with linkage to pet's client still works (regression) - Customer cannot view another client's pet (cross-tenant block) Full @groombook/api test suite: 560 passed (39 files). Note (out of scope): the apps/api/ duplicate tree is dead code producing false-green coverage — recommend filing a separate tech-debt issue to delete apps/api/ or wire it into the workspace, but not blocking this fix on it. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Pet Profile Summary Tests
|
||||
*
|
||||
* Covers GET /api/pets/:id/profile-summary in the deployed tree
|
||||
* (root src/). The headline cases validate the GRO-2013 owner-bypass:
|
||||
* a customer who is auto-provisioned as a `groomer` staff row by rbac.ts
|
||||
* (with no appointment linkage) may still read their own pet's summary
|
||||
* when they supply a valid X-Impersonation-Session-Id whose clientId
|
||||
* matches the pet's clientId.
|
||||
*
|
||||
* Deployed tree: src/routes/pets.ts. This test mirrors the live handler
|
||||
* (which queries the `appointments` table for visit history, not
|
||||
* `groomingVisitLogs`).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
|
||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
icalToken: null,
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors the auto-provisioned "groomer" staff row rbac.ts creates for an
|
||||
* OIDC user (e.g. uat-customer@groombook.dev) on first login: role=groomer,
|
||||
* no appointment linkage.
|
||||
*/
|
||||
const CUSTOMER_STAFF: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-customer-id",
|
||||
oidcSub: null,
|
||||
userId: "user-customer-id",
|
||||
role: "groomer",
|
||||
name: "UAT Customer",
|
||||
email: "uat-customer@groombook.dev",
|
||||
};
|
||||
|
||||
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
||||
|
||||
const CLIENT_ID = "c0000001-0000-0000-0000-000000000001";
|
||||
const PET_ID = "c0000001-0000-0000-0000-000000000002";
|
||||
const OTHER_CLIENT_PET_ID = "c0000002-0000-0000-0000-000000000099";
|
||||
|
||||
const futureDate = () => new Date(Date.now() + 30 * 60_000);
|
||||
const pastDate = () => new Date(Date.now() - 5 * 60_000);
|
||||
|
||||
function makePet(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: PET_ID,
|
||||
clientId: CLIENT_ID,
|
||||
name: "Biscuit",
|
||||
species: "dog",
|
||||
breed: "Golden Retriever",
|
||||
weightKg: "30.00",
|
||||
dateOfBirth: null,
|
||||
healthAlerts: null,
|
||||
groomingNotes: null,
|
||||
cutStyle: null,
|
||||
shampooPreference: null,
|
||||
specialCareNotes: null,
|
||||
customFields: {},
|
||||
petSizeCategory: "large",
|
||||
coatType: "double",
|
||||
photoKey: null,
|
||||
photoUploadedAt: null,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAppointment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "appt-1",
|
||||
clientId: CLIENT_ID,
|
||||
petId: PET_ID,
|
||||
serviceId: "service-1",
|
||||
staffId: GROOMER.id,
|
||||
batherStaffId: null,
|
||||
status: "completed",
|
||||
startTime: new Date("2024-06-01T09:00:00Z"),
|
||||
endTime: new Date("2024-06-01T11:00:00Z"),
|
||||
notes: null,
|
||||
priceCents: 6000,
|
||||
seriesId: null,
|
||||
seriesIndex: null,
|
||||
groupId: null,
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: null,
|
||||
cancelledAt: null,
|
||||
confirmationToken: null,
|
||||
customerNotes: null,
|
||||
createdAt: new Date("2024-05-15"),
|
||||
updatedAt: new Date("2024-05-15"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeService(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "service-1",
|
||||
name: "Full Groom",
|
||||
description: null,
|
||||
basePriceCents: 6000,
|
||||
durationMinutes: 120,
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "sess-owner",
|
||||
staffId: CUSTOMER_STAFF.id,
|
||||
clientId: CLIENT_ID,
|
||||
reason: "sso-bridge",
|
||||
status: "active",
|
||||
startedAt: new Date(),
|
||||
endedAt: null,
|
||||
expiresAt: futureDate(),
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── DB mock state ────────────────────────────────────────────────────────────
|
||||
|
||||
let petsTable: Record<string, unknown>[];
|
||||
let appointmentsTable: Record<string, unknown>[];
|
||||
let servicesTable: Record<string, unknown>[];
|
||||
let sessionsTable: Record<string, unknown>[];
|
||||
|
||||
// selectQueue: queries resolve in FIFO order. Each .from(table) result
|
||||
// returns a chain that resolves to the next queued row set on a terminal
|
||||
// call (.where()/.orderBy()/.limit()).
|
||||
let selectQueue: Array<{ table: string; rows: unknown[] }> = [];
|
||||
|
||||
function enqueue(table: string, rows: unknown[] = []) {
|
||||
selectQueue.push({ table, rows });
|
||||
}
|
||||
|
||||
function resetMock() {
|
||||
petsTable = [makePet()];
|
||||
appointmentsTable = [makeAppointment()];
|
||||
servicesTable = [makeService()];
|
||||
sessionsTable = [makeSession()];
|
||||
selectQueue = [];
|
||||
}
|
||||
|
||||
// ─── Module mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
function makeTable(name: string) {
|
||||
return new Proxy(
|
||||
{ _name: name },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop === "_name") return name;
|
||||
if (prop === "$inferSelect") return {};
|
||||
return { table: name, column: prop };
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
|
||||
const queryString = _strings[0];
|
||||
return {
|
||||
queryChunks: [queryString],
|
||||
as: (alias: string) => ({
|
||||
queryChunks: [queryString],
|
||||
fieldAlias: alias,
|
||||
getSQL() { return this.queryChunks; },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function takeQueuedRows(tableName: string): unknown[] {
|
||||
const next = selectQueue.shift();
|
||||
if (next && next.table === tableName) return next.rows;
|
||||
return [];
|
||||
}
|
||||
|
||||
// Wrap a finalised result in a Proxy that exposes chainable methods
|
||||
// and the resolved rows. Each call to a chainable method (where/orderBy/
|
||||
// limit/...) returns the SAME rows so the route's natural await on the
|
||||
// chain resolves to the queued data.
|
||||
function wrapRows(rows: unknown[]): unknown {
|
||||
return new Proxy(rows, {
|
||||
get(target, prop: string | symbol) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit"
|
||||
|| prop === "leftJoin" || prop === "innerJoin" || prop === "from") {
|
||||
return () => wrapRows(rows);
|
||||
}
|
||||
if (prop === "then") {
|
||||
return (onFulfilled?: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) =>
|
||||
Promise.resolve(rows).then(onFulfilled, onRejected);
|
||||
}
|
||||
if (prop === Symbol.iterator) {
|
||||
return function* () { for (const v of target) yield v; };
|
||||
}
|
||||
if (prop === Symbol.asyncIterator) {
|
||||
return async function* () { for (const v of target) yield v; };
|
||||
}
|
||||
// @ts-expect-error proxy access
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: (_cols?: Record<string, unknown>) => ({
|
||||
from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")),
|
||||
}),
|
||||
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
|
||||
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
|
||||
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
|
||||
}),
|
||||
pets: makeTable("pets"),
|
||||
appointments: makeTable("appointments"),
|
||||
staff: makeTable("staff"),
|
||||
services: makeTable("services"),
|
||||
impersonationSessions: makeTable("impersonationSessions"),
|
||||
and: vi.fn((..._args: unknown[]) => ({})),
|
||||
desc: vi.fn((c: unknown) => c),
|
||||
eq: vi.fn((_a: unknown, _b: unknown) => ({})),
|
||||
exists: vi.fn(() => true),
|
||||
or: vi.fn((..._args: unknown[]) => ({})),
|
||||
sql: sqlMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/s3.js", () => ({
|
||||
getPresignedUploadUrl: vi.fn(),
|
||||
getPresignedGetUrl: vi.fn(),
|
||||
deleteObject: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Import after mocks are set up ────────────────────────────────────────────
|
||||
|
||||
const { petsRouter } = await import("../routes/pets.js");
|
||||
|
||||
// ─── App builder ──────────────────────────────────────────────────────────────
|
||||
|
||||
function buildApp(staffRow: StaffRow) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub ?? staffRow.userId ?? "" });
|
||||
c.set("staff", staffRow);
|
||||
await next();
|
||||
});
|
||||
app.route("/pets", petsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
// ─── Reset before each test ───────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
resetMock();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /:id/profile-summary — basic access", () => {
|
||||
it("returns 404 when the pet does not exist", async () => {
|
||||
petsTable = [];
|
||||
enqueue("pets", []);
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 200 with aggregated profile for a manager", async () => {
|
||||
// Query order: pets, recent history, visit count, upcoming
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("appointments", appointmentsTable);
|
||||
enqueue("appointments", [{ count: 1 }]);
|
||||
enqueue("appointments", []);
|
||||
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.id).toBe(PET_ID);
|
||||
expect(body.name).toBe("Biscuit");
|
||||
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
|
||||
expect(body.visitCount).toBe(1);
|
||||
expect(body.upcomingAppointment).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 200 for a groomer with appointment linkage to the pet's client", async () => {
|
||||
// Query order: pets, linkage check, recent history, visit count, upcoming
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
|
||||
enqueue("appointments", appointmentsTable); // history
|
||||
enqueue("appointments", [{ count: 1 }]);
|
||||
enqueue("appointments", []);
|
||||
|
||||
const app = buildApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 403 for a groomer with no appointment linkage and no bypass header", async () => {
|
||||
// Query order: pets, linkage check (returns empty → 403)
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("appointments", []); // no linkage
|
||||
|
||||
const app = buildApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GRO-2013 owner-bypass ────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /:id/profile-summary — owner-bypass (GRO-2013)", () => {
|
||||
it("customer-as-groomer with valid active session for pet's client returns 200", async () => {
|
||||
// Query order: pets, session lookup (found, active, future), recent history,
|
||||
// visit count, upcoming
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("impersonationSessions", sessionsTable); // active session found
|
||||
enqueue("appointments", appointmentsTable);
|
||||
enqueue("appointments", [{ count: 1 }]);
|
||||
enqueue("appointments", []);
|
||||
|
||||
const app = buildApp(CUSTOMER_STAFF);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
|
||||
headers: { "X-Impersonation-Session-Id": "sess-owner" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.id).toBe(PET_ID);
|
||||
});
|
||||
|
||||
it("customer-as-groomer with no header still gets 403 (no bypass)", async () => {
|
||||
// Query order: pets, session lookup (header missing → returns [], 403)
|
||||
enqueue("pets", petsTable);
|
||||
|
||||
const app = buildApp(CUSTOMER_STAFF);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("customer-as-groomer with session for a DIFFERENT client gets 403 (cross-tenant blocked)", async () => {
|
||||
// Session exists but clientId !== pet.clientId → bypass does not apply
|
||||
// → falls through to groomer linkage check → no linkage → 403
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("impersonationSessions", [
|
||||
makeSession({
|
||||
id: "sess-other-client",
|
||||
clientId: "c0000000-0000-0000-0000-000000000099", // different from CLIENT_ID
|
||||
}),
|
||||
]);
|
||||
enqueue("appointments", []); // no linkage → 403
|
||||
|
||||
const app = buildApp(CUSTOMER_STAFF);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
|
||||
headers: { "X-Impersonation-Session-Id": "sess-other-client" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("customer-as-groomer with expired session still gets 403", async () => {
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("impersonationSessions", [
|
||||
makeSession({ id: "sess-expired", expiresAt: pastDate() }),
|
||||
]);
|
||||
enqueue("appointments", []); // no linkage → 403
|
||||
|
||||
const app = buildApp(CUSTOMER_STAFF);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
|
||||
headers: { "X-Impersonation-Session-Id": "sess-expired" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("customer-as-groomer with ended (status != active) session still gets 403", async () => {
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("impersonationSessions", [
|
||||
makeSession({ id: "sess-ended", status: "ended" }),
|
||||
]);
|
||||
enqueue("appointments", []); // no linkage → 403
|
||||
|
||||
const app = buildApp(CUSTOMER_STAFF);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
|
||||
headers: { "X-Impersonation-Session-Id": "sess-ended" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("customer-as-groomer with unknown session id still gets 403", async () => {
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("impersonationSessions", []); // session not found
|
||||
enqueue("appointments", []); // no linkage → 403
|
||||
|
||||
const app = buildApp(CUSTOMER_STAFF);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
|
||||
headers: { "X-Impersonation-Session-Id": "sess-unknown" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("manager does NOT need the impersonation header (existing role check still works)", async () => {
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("appointments", appointmentsTable);
|
||||
enqueue("appointments", [{ count: 1 }]);
|
||||
enqueue("appointments", []);
|
||||
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("groomer with linkage to pet's client still works (regression — no regression from bypass)", async () => {
|
||||
enqueue("pets", petsTable);
|
||||
enqueue("appointments", [{ id: "appt-1" }]); // linkage found
|
||||
enqueue("appointments", appointmentsTable);
|
||||
enqueue("appointments", [{ count: 1 }]);
|
||||
enqueue("appointments", []);
|
||||
|
||||
const app = buildApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("owner-bypass: customer cannot view another client's pet (cross-tenant block)", async () => {
|
||||
// The customer has a valid session for CLIENT_ID, but the pet belongs
|
||||
// to a different client → isOwner=false → falls through to groomer
|
||||
// linkage check → 403.
|
||||
enqueue("pets", [
|
||||
makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }),
|
||||
]);
|
||||
enqueue("impersonationSessions", sessionsTable); // valid session, but for CLIENT_ID
|
||||
enqueue("appointments", []); // no linkage → 403
|
||||
|
||||
const app = buildApp(CUSTOMER_STAFF);
|
||||
const res = await app.request(`/pets/${OTHER_CLIENT_PET_ID}/profile-summary`, {
|
||||
headers: { "X-Impersonation-Session-Id": "sess-owner" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
+41
-1
@@ -7,6 +7,7 @@ import {
|
||||
eq,
|
||||
exists,
|
||||
getDb,
|
||||
impersonationSessions,
|
||||
or,
|
||||
pets,
|
||||
appointments,
|
||||
@@ -109,6 +110,35 @@ petsRouter.get("/:id", async (c) => {
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves the clientId from the X-Impersonation-Session-Id header, if present and active.
|
||||
* Used by staff routes to allow a customer (auto-provisioned as a `groomer` staff row
|
||||
* by rbac.ts) to access their own pet's data when they are the rightful owner.
|
||||
*
|
||||
* Returns null when the header is missing, the session is unknown/expired/ended, or the
|
||||
* session exists but has no clientId — callers should treat null as "no owner-bypass".
|
||||
*/
|
||||
async function resolveImpersonationClientId(
|
||||
db: ReturnType<typeof getDb>,
|
||||
c: { req: { header: (name: string) => string | undefined } }
|
||||
): Promise<string | null> {
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
if (!sessionId) return null;
|
||||
const [session] = await db
|
||||
.select({
|
||||
clientId: impersonationSessions.clientId,
|
||||
status: impersonationSessions.status,
|
||||
expiresAt: impersonationSessions.expiresAt,
|
||||
})
|
||||
.from(impersonationSessions)
|
||||
.where(eq(impersonationSessions.id, sessionId))
|
||||
.limit(1);
|
||||
if (!session) return null;
|
||||
if (session.status !== "active") return null;
|
||||
if (session.expiresAt <= new Date()) return null;
|
||||
return session.clientId;
|
||||
}
|
||||
|
||||
petsRouter.get("/:id/profile-summary", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("id");
|
||||
@@ -119,8 +149,18 @@ petsRouter.get("/:id/profile-summary", async (c) => {
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
if (!pet) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
// Groomer RBAC: check appointment linkage to this pet's client
|
||||
// Owner-bypass (GRO-2013): a customer who supplies a valid
|
||||
// X-Impersonation-Session-Id for the pet's owning client may read their
|
||||
// own pet's summary, even though rbac.ts auto-provisions them as a
|
||||
// `groomer` staff row with no appointment linkage.
|
||||
let isOwner = false;
|
||||
if (isGroomer) {
|
||||
const ownerClientId = await resolveImpersonationClientId(db, c);
|
||||
isOwner = !!ownerClientId && ownerClientId === pet.clientId;
|
||||
}
|
||||
|
||||
// Groomer RBAC: check appointment linkage to this pet's client
|
||||
if (isGroomer && !isOwner) {
|
||||
const [linkage] = await db
|
||||
.select({ id: appointments.id })
|
||||
.from(appointments)
|
||||
|
||||
Reference in New Issue
Block a user