security(audit): log owner-bypass reads in GET /pets/:id/profile-summary (GRO-2062)
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 1m16s

Adds a defense-in-depth audit row to impersonationAuditLogs when the
staff-side owner-bypass path fires. Mirrors the failure-isolation
pattern in src/middleware/portalAudit.ts: insert failures are logged
and swallowed so a working read can never turn into a 500.

- New writeOwnerBypassAudit helper called only when isOwner === true.
- No DB migration; petId + actorStaffId go inside metadata jsonb.
- resolveImpersonationClientId stays pure (no audit side effects).
- Positive + negative tests + a cross-tenant regression test.
- UAT_PLAYBOOK.md §3.19d: TC-API-3.19d documents the audit assertion.

Parent tracking: GRO-2062 (Paperclip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 04:10:58 +00:00
parent 91eb2ccf71
commit 1f888ac716
3 changed files with 129 additions and 1 deletions
+79 -1
View File
@@ -182,6 +182,11 @@ let selectQueue: Array<{
throw?: string;
}> = [];
// Captured `db.insert(table).values(vals)` calls. Mirrors the pattern from
// src/__tests__/impersonation.test.ts so the GRO-2063 audit row assertions
// can inspect what the route tried to write without needing a real DB.
let insertCapture: Array<{ table: string; vals: Record<string, unknown> }> = [];
function enqueue(table: string, rows: unknown[] = []) {
selectQueue.push({ table, rows });
}
@@ -196,6 +201,7 @@ function resetMock() {
servicesTable = [makeService()];
sessionsTable = [makeSession()];
selectQueue = [];
insertCapture = [];
}
// ─── Module mocks ───────────────────────────────────────────────────────────
@@ -269,7 +275,12 @@ vi.mock("@groombook/db", () => {
select: (_cols?: Record<string, unknown>) => ({
from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")),
}),
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
insert: (table: { _name?: string }) => ({
values: (vals: Record<string, unknown>) => {
insertCapture.push({ table: table._name ?? "unknown", vals });
return { returning: () => [{}] };
},
}),
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
}),
@@ -278,6 +289,7 @@ vi.mock("@groombook/db", () => {
staff: makeTable("staff"),
services: makeTable("services"),
impersonationSessions: makeTable("impersonationSessions"),
impersonationAuditLogs: makeTable("impersonation_audit_logs"),
and: vi.fn((..._args: unknown[]) => ({})),
desc: vi.fn((c: unknown) => c),
eq: vi.fn((_a: unknown, _b: unknown) => ({})),
@@ -567,3 +579,69 @@ describe("GET /:id/profile-summary — owner-bypass (GRO-2013)", () => {
expect(res.status).toBe(403);
});
});
// ─── GRO-2063 owner-bypass audit write ──────────────────────────────────────
describe("GET /:id/profile-summary — owner-bypass audit row (GRO-2063)", () => {
it("writes exactly one audit row on the owner-bypass success path", async () => {
enqueue("pets", petsTable);
enqueue("impersonationSessions", sessionsTable); // valid active session for CLIENT_ID
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 auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(1);
const vals = auditInserts[0]!.vals;
expect(vals.action).toBe("read_profile_summary");
expect(vals.sessionId).toBe("sess-owner");
expect(vals.pageVisited).toBe(`/pets/${PET_ID}/profile-summary`);
expect(vals.metadata).toEqual({
petId: PET_ID,
actorStaffId: CUSTOMER_STAFF.id,
});
});
it("does NOT write an audit row on the normal groomer-linkage success path", async () => {
// GROOMER is a "real" groomer with appointment linkage, NOT the
// auto-provisioned customer-as-groomer. No impersonation header is
// present, so the owner-bypass branch never executes.
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);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(0);
});
it("does NOT write an audit row when the owner-bypass attempt is denied (cross-tenant)", async () => {
// Customer has a valid session but it points at a different client.
// isOwner=false, falls through to groomer linkage check, returns 403.
enqueue("pets", [
makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }),
]);
enqueue("impersonationSessions", sessionsTable); // session is 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);
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
expect(auditInserts).toHaveLength(0);
});
});
+49
View File
@@ -7,6 +7,7 @@ import {
eq,
exists,
getDb,
impersonationAuditLogs,
impersonationSessions,
or,
pets,
@@ -156,6 +157,40 @@ async function resolveImpersonationClientId(
return session.clientId;
}
/**
* Defense-in-depth audit write for the staff-side owner-bypass path in
* GET /pets/:id/profile-summary. Mirrors the failure-isolation pattern in
* src/middleware/portalAudit.ts: errors are logged but never thrown, so a
* misbehaving audit insert cannot turn a working read into a 500.
*
* Called only when the owner-bypass actually fires (i.e. the requester is a
* groomer-role staff row with no appointment linkage, but supplies a valid
* X-Impersonation-Session-Id whose clientId matches the pet's owner). The
* `petId` and `actorStaffId` are written inside `metadata` because the
* impersonation_audit_logs schema has no first-class columns for them and
* adding a migration is out of scope.
*/
async function writeOwnerBypassAudit(
db: ReturnType<typeof getDb>,
args: {
sessionId: string;
petId: string;
actorStaffId: string;
pageVisited: string;
}
): Promise<void> {
try {
await db.insert(impersonationAuditLogs).values({
sessionId: args.sessionId,
action: "read_profile_summary",
pageVisited: args.pageVisited,
metadata: { petId: args.petId, actorStaffId: args.actorStaffId },
});
} catch (err) {
console.error("[pets] failed to write owner-bypass audit log:", err);
}
}
petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb();
const petId = c.req.param("id");
@@ -188,8 +223,22 @@ petsRouter.get("/:id/profile-summary", async (c) => {
// `groomer` staff row with no appointment linkage.
let isOwner = false;
if (isGroomer) {
const headerSessionId = c.req.header("X-Impersonation-Session-Id");
const ownerClientId = await resolveImpersonationClientId(db, c);
isOwner = !!ownerClientId && ownerClientId === pet.clientId;
if (isOwner && headerSessionId) {
// GRO-2063: defense-in-depth audit row. Only fires when the bypass
// is actually granted; never on the normal groomer-linkage path,
// 403/404/401, or when the header is absent. Failure is swallowed
// (try/catch inside writeOwnerBypassAudit) so this can never turn a
// working read into a 500.
await writeOwnerBypassAudit(db, {
sessionId: headerSessionId,
petId: pet.id,
actorStaffId: staffRow.id,
pageVisited: c.req.path,
});
}
}
// Groomer RBAC: check appointment linkage to this pet's client