diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 15a7756..d4cc1e7 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -160,12 +160,14 @@ Reasoning: - `listCalendars`, `listCalendarsDetail`, `getCalendarByIdentifier`, `getCalendarByIdentifierDetail`, `getCalendarById`: `admin-only` - create, update, delete calendar and entry mutations: `admin-only` -- holiday resolution and preview helpers remain unchanged in this rollout +- `previewResolvedHolidays`, `previewResolvedHolidaysDetail`, `resolveHolidays`, `resolveHolidaysDetail`: `authenticated-safe-lookup` +- `resolveResourceHolidays`, `resolveResourceHolidaysDetail`: `self-service` for the caller's own resource, with elevated cross-resource reads for manager and admin roles Reasoning: - the calendar catalog is currently consumed in the web app only by the admin vacation editor, so broad authenticated reads expose internal configuration without a product need -- narrowing just the catalog reads keeps the hardening slice small while avoiding regressions in shared holiday-resolution helpers used by vacation, timeline, and assistant flows +- region-based resolution helpers remain a narrow lookup surface because callers provide the location context directly instead of enumerating internal resource data +- resource-based holiday resolution derives sensitive location context from a specific employee record, so it must follow the same self-service ownership model as other resource-scoped absence reads ### `packages/api/src/router/entitlement.ts` diff --git a/packages/api/src/__tests__/holiday-calendar-router-auth.test.ts b/packages/api/src/__tests__/holiday-calendar-router-auth.test.ts new file mode 100644 index 0000000..d176b59 --- /dev/null +++ b/packages/api/src/__tests__/holiday-calendar-router-auth.test.ts @@ -0,0 +1,179 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { createCallerFactory } from "../trpc.js"; +import { holidayCalendarRouter } from "../router/holiday-calendar.js"; + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +const createCaller = createCallerFactory(holidayCalendarRouter); + +function createContext( + db: Record, + options: { + role?: SystemRole; + session?: boolean; + } = {}, +) { + const { role = SystemRole.USER, session = true } = options; + + return { + session: session + ? { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + } + : null, + db: db as never, + dbUser: session + ? { + id: role === SystemRole.ADMIN ? "user_admin" : role === SystemRole.MANAGER ? "user_mgr" : "user_1", + systemRole: role, + permissionOverrides: null, + } + : null, + }; +} + +function resolveInput(resourceId: string) { + return { + resourceId, + periodStart: new Date("2026-01-01T00:00:00.000Z"), + periodEnd: new Date("2026-12-31T00:00:00.000Z"), + }; +} + +describe("holiday calendar router authorization", () => { + it("requires authentication for resource holiday resolution", async () => { + const resourceFindFirst = vi.fn(); + const resourceFindUnique = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findFirst: resourceFindFirst, + findUnique: resourceFindUnique, + }, + holidayCalendar: { + findMany: vi.fn(), + }, + }, { session: false })); + + await expect(caller.resolveResourceHolidays(resolveInput("res_1"))).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + + expect(resourceFindFirst).not.toHaveBeenCalled(); + expect(resourceFindUnique).not.toHaveBeenCalled(); + }); + + it("forbids regular users from resolving another resource's holidays", async () => { + const resourceFindUnique = vi.fn(); + const holidayCalendarFindMany = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_own" }), + findUnique: resourceFindUnique, + }, + holidayCalendar: { + findMany: holidayCalendarFindMany, + }, + })); + + await expect(caller.resolveResourceHolidays(resolveInput("res_other"))).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only view holiday data for your own resource", + }); + + expect(resourceFindUnique).not.toHaveBeenCalled(); + expect(holidayCalendarFindMany).not.toHaveBeenCalled(); + }); + + it("allows regular users to resolve holidays for their own resource", async () => { + const resourceFindUnique = vi.fn().mockResolvedValue({ + id: "res_own", + eid: "EMP-001", + displayName: "Alice Example", + federalState: "BY", + countryId: "country_de", + metroCityId: null, + country: { code: "DE", name: "Deutschland" }, + metroCity: null, + }); + const caller = createCaller(createContext({ + resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_own" }), + findUnique: resourceFindUnique, + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + })); + + const result = await caller.resolveResourceHolidays(resolveInput("res_own")); + + expect(result.resource).toEqual({ + id: "res_own", + eid: "EMP-001", + name: "Alice Example", + country: "Deutschland", + countryCode: "DE", + federalState: "BY", + metroCity: null, + }); + expect(resourceFindUnique).toHaveBeenCalledWith({ + where: { id: "res_own" }, + select: { + id: true, + eid: true, + displayName: true, + federalState: true, + countryId: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + }, + }); + }); + + it("allows managers to resolve holidays for other resources", async () => { + const resourceFindFirst = vi.fn(); + const resourceFindUnique = vi.fn().mockResolvedValue({ + id: "res_other", + eid: "EMP-002", + displayName: "Bob Example", + federalState: "BY", + countryId: "country_de", + metroCityId: null, + country: { code: "DE", name: "Deutschland" }, + metroCity: null, + }); + const caller = createCaller(createContext({ + resource: { + findFirst: resourceFindFirst, + findUnique: resourceFindUnique, + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, { role: SystemRole.MANAGER })); + + const result = await caller.resolveResourceHolidays(resolveInput("res_other")); + + expect(result.resource.id).toBe("res_other"); + expect(resourceFindFirst).not.toHaveBeenCalled(); + expect(resourceFindUnique).toHaveBeenCalledWith({ + where: { id: "res_other" }, + select: { + id: true, + eid: true, + displayName: true, + federalState: true, + countryId: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + }, + }); + }); +}); diff --git a/packages/api/src/__tests__/holiday-calendar-router.test.ts b/packages/api/src/__tests__/holiday-calendar-router.test.ts index 4b44988..873af74 100644 --- a/packages/api/src/__tests__/holiday-calendar-router.test.ts +++ b/packages/api/src/__tests__/holiday-calendar-router.test.ts @@ -435,6 +435,7 @@ describe("holiday calendar router", () => { it("formats resolved holidays for a resource including local city holidays", async () => { const db = { resource: { + findFirst: vi.fn().mockResolvedValue({ id: "res_1" }), findUnique: vi.fn().mockResolvedValue({ id: "res_1", eid: "bruce.banner", diff --git a/packages/api/src/router/holiday-calendar.ts b/packages/api/src/router/holiday-calendar.ts index d684d54..89a1571 100644 --- a/packages/api/src/router/holiday-calendar.ts +++ b/packages/api/src/router/holiday-calendar.ts @@ -14,7 +14,7 @@ import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js"; type HolidayCalendarScope = HolidayCalendarScopeInput; -type HolidayReadContext = Pick; +type HolidayReadContext = Pick; const HOLIDAY_SCOPE = { COUNTRY: "COUNTRY", @@ -119,6 +119,45 @@ function formatHolidayCalendarDetail(calendar: { }; } +function canManageHolidayResourceReads(ctx: { dbUser: { systemRole: string } | null }): boolean { + const role = ctx.dbUser?.systemRole; + return role === "ADMIN" || role === "MANAGER"; +} + +async function findOwnedHolidayResourceId(ctx: HolidayReadContext): Promise { + if (!ctx.dbUser?.id) { + return null; + } + + if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { + return null; + } + + const resource = await ctx.db.resource.findFirst({ + where: { userId: ctx.dbUser.id }, + select: { id: true }, + }); + + return resource?.id ?? null; +} + +async function assertCanReadHolidayResource( + ctx: HolidayReadContext, + resourceId: string, +): Promise { + if (canManageHolidayResourceReads(ctx)) { + return; + } + + const ownedResourceId = await findOwnedHolidayResourceId(ctx); + if (!ownedResourceId || ownedResourceId !== resourceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only view holiday data for your own resource", + }); + } +} + function formatResolvedHolidayDetail(holiday: { date: string; name: string; @@ -396,6 +435,8 @@ async function readResolvedResourceHolidaysSnapshot( ctx: HolidayReadContext, input: z.infer, ) { + await assertCanReadHolidayResource(ctx, input.resourceId); + const resource = await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.resourceId },