diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 0c59b3d..0f68c53 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -40,7 +40,7 @@ ### `packages/api/src/router/allocation.ts` -- broad planning and staffing reads should move from generic `protectedProcedure` to explicit `planning-read` or narrower follow-up audiences +- `list`, `listView`, `listDemands`, `listAssignments`, `getAssignmentById`, `resolveAssignment`, `getDemandRequirementById`, `checkResourceAvailability`, `getResourceAvailabilityView`, `getResourceAvailabilitySummary`: `planning-read` - mutations already sit behind `manager-write` ### `packages/api/src/router/dashboard.ts` @@ -49,6 +49,6 @@ ## Immediate Follow-Ups -- reclassify `allocation` read endpoints away from generic `protectedProcedure` - introduce a dedicated project-read permission instead of the current interim `planning-read` composite +- split `allocation` further into narrower future audiences where resource-capacity and staffing-demand reads diverge - add authorization tests for every route listed above so the matrix is CI-enforced, not just documented diff --git a/packages/api/src/__tests__/allocation-router.test.ts b/packages/api/src/__tests__/allocation-router.test.ts index 8c70bc4..1417482 100644 --- a/packages/api/src/__tests__/allocation-router.test.ts +++ b/packages/api/src/__tests__/allocation-router.test.ts @@ -62,6 +62,123 @@ function createManagerCaller(db: Record) { }); } +function createControllerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "controller@example.com", name: "Controller", image: null }, + expires: "2026-03-13T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_controller", + systemRole: SystemRole.CONTROLLER, + permissionOverrides: null, + }, + }); +} + +function createProtectedCaller(db: Record) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2026-03-13T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: null, + }, + }); +} + +describe("allocation router authorization", () => { + const planningWindow = { + resourceId: "resource_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-02T00:00:00.000Z"), + hoursPerDay: 8, + }; + + it("allows controllers to read assignment lists through the planning audience", async () => { + const assignment = { + id: "assignment_1", + demandRequirementId: null, + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-02T00:00:00.000Z"), + hoursPerDay: 8, + percentage: 100, + role: "Compositor", + roleId: "role_comp", + dailyCostCents: 40000, + status: AllocationStatus.ACTIVE, + metadata: {}, + resource: null, + project: { id: "project_1", name: "Project One", shortCode: "PRJ" }, + roleEntity: null, + demandRequirement: null, + }; + const db = { + assignment: { + findMany: vi.fn().mockResolvedValue([assignment]), + }, + systemSettings: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + + const caller = createControllerCaller(db); + const result = await caller.listAssignments({}); + + expect(result).toEqual([assignment]); + expect(db.assignment.findMany).toHaveBeenCalledWith({ + where: {}, + include: expect.any(Object), + orderBy: { startDate: "asc" }, + }); + }); + + it.each([ + { name: "list", invoke: (caller: ReturnType) => caller.list({}) }, + { name: "listView", invoke: (caller: ReturnType) => caller.listView({}) }, + { name: "listDemands", invoke: (caller: ReturnType) => caller.listDemands({}) }, + { name: "listAssignments", invoke: (caller: ReturnType) => caller.listAssignments({}) }, + { + name: "getAssignmentById", + invoke: (caller: ReturnType) => caller.getAssignmentById({ id: "assignment_1" }), + }, + { + name: "resolveAssignment", + invoke: (caller: ReturnType) => caller.resolveAssignment({ assignmentId: "assignment_1" }), + }, + { + name: "getDemandRequirementById", + invoke: (caller: ReturnType) => caller.getDemandRequirementById({ id: "demand_1" }), + }, + { + name: "checkResourceAvailability", + invoke: (caller: ReturnType) => caller.checkResourceAvailability(planningWindow), + }, + { + name: "getResourceAvailabilityView", + invoke: (caller: ReturnType) => caller.getResourceAvailabilityView(planningWindow), + }, + { + name: "getResourceAvailabilitySummary", + invoke: (caller: ReturnType) => caller.getResourceAvailabilitySummary(planningWindow), + }, + ])("requires planning read access for $name", async ({ invoke }) => { + const caller = createProtectedCaller({}); + + await expect(invoke(caller)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + }); +}); + function createDemandWorkflowDb(overrides: Record = {}) { const db = { project: { diff --git a/packages/api/src/router/allocation.ts b/packages/api/src/router/allocation.ts index 809b997..673b775 100644 --- a/packages/api/src/router/allocation.ts +++ b/packages/api/src/router/allocation.ts @@ -43,7 +43,7 @@ import { countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; -import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; +import { createTRPCRouter, managerProcedure, planningReadProcedure, requirePermission } from "../trpc.js"; import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js"; const DEMAND_INCLUDE = { @@ -658,7 +658,7 @@ function buildResourceAvailabilitySummary( } export const allocationRouter = createTRPCRouter({ - list: protectedProcedure + list: planningReadProcedure .input( z.object({ projectId: z.string().optional(), @@ -671,7 +671,7 @@ export const allocationRouter = createTRPCRouter({ return readModel.allocations; }), - listView: protectedProcedure + listView: planningReadProcedure .input( z.object({ projectId: z.string().optional(), @@ -746,7 +746,7 @@ export const allocationRouter = createTRPCRouter({ return allocation; }), - listDemands: protectedProcedure + listDemands: planningReadProcedure .input( z.object({ projectId: z.string().optional(), @@ -774,7 +774,7 @@ export const allocationRouter = createTRPCRouter({ })); }), - listAssignments: protectedProcedure + listAssignments: planningReadProcedure .input( z.object({ projectId: z.string().optional(), @@ -801,7 +801,7 @@ export const allocationRouter = createTRPCRouter({ ); }), - getAssignmentById: protectedProcedure + getAssignmentById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const assignment = await findUniqueOrThrow( @@ -821,7 +821,7 @@ export const allocationRouter = createTRPCRouter({ }; }), - resolveAssignment: protectedProcedure + resolveAssignment: planningReadProcedure .input(z.object({ assignmentId: z.string().optional(), resourceId: z.string().optional(), @@ -833,7 +833,7 @@ export const allocationRouter = createTRPCRouter({ })) .query(async ({ ctx, input }) => resolveAssignmentBySelection(ctx.db, input)), - getDemandRequirementById: protectedProcedure + getDemandRequirementById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => getDemandRequirementByIdOrThrow(ctx.db, input.id)), @@ -841,7 +841,7 @@ export const allocationRouter = createTRPCRouter({ * Check a resource's availability for a date range. * Returns working days, existing allocations, conflict days, and available capacity. */ - checkResourceAvailability: protectedProcedure + checkResourceAvailability: planningReadProcedure .input(z.object({ resourceId: z.string(), startDate: z.coerce.date(), @@ -853,7 +853,7 @@ export const allocationRouter = createTRPCRouter({ return availability; }), - getResourceAvailabilityView: protectedProcedure + getResourceAvailabilityView: planningReadProcedure .input(z.object({ resourceId: z.string(), startDate: z.coerce.date(), @@ -862,7 +862,7 @@ export const allocationRouter = createTRPCRouter({ })) .query(async ({ ctx, input }) => buildResourceAvailabilityView(ctx.db, input)), - getResourceAvailabilitySummary: protectedProcedure + getResourceAvailabilitySummary: planningReadProcedure .input(z.object({ resourceId: z.string(), startDate: z.coerce.date(),