diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index 3f6903e..8b32bb8 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -208,8 +208,12 @@ describe("assistant router tool gating", () => { }); it("hides advanced tools unless the dedicated assistant permission is granted", () => { - const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]); + const withoutAdvanced = getToolNames([ + PermissionKey.VIEW_PLANNING, + PermissionKey.VIEW_COSTS, + ]); const withAdvanced = getToolNames([ + PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, ]); @@ -382,9 +386,35 @@ describe("assistant router tool gating", () => { expect(userWithoutPlanning).not.toContain("list_allocations"); expect(userWithoutPlanning).not.toContain("list_demands"); expect(userWithoutPlanning).not.toContain("check_resource_availability"); + expect(userWithoutPlanning).not.toContain("find_capacity"); + expect(userWithoutPlanning).not.toContain("get_staffing_suggestions"); + expect(userWithoutPlanning).not.toContain("find_best_project_resource"); expect(userWithPlanning).toContain("list_allocations"); expect(userWithPlanning).toContain("list_demands"); expect(userWithPlanning).toContain("check_resource_availability"); + expect(userWithPlanning).toContain("find_capacity"); + expect(userWithPlanning).not.toContain("get_staffing_suggestions"); + expect(userWithPlanning).not.toContain("find_best_project_resource"); + }); + + it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => { + const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER); + const planningAndCosts = getToolNames([ + PermissionKey.VIEW_PLANNING, + PermissionKey.VIEW_COSTS, + ], SystemRole.USER); + const planningCostsAndAdvanced = getToolNames([ + PermissionKey.VIEW_PLANNING, + PermissionKey.VIEW_COSTS, + PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, + ], SystemRole.USER); + + expect(planningOnly).not.toContain("get_staffing_suggestions"); + expect(planningOnly).not.toContain("find_best_project_resource"); + expect(planningAndCosts).toContain("get_staffing_suggestions"); + expect(planningAndCosts).not.toContain("find_best_project_resource"); + expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions"); + expect(planningCostsAndAdvanced).toContain("find_best_project_resource"); }); it("keeps controller-only project and dashboard reads hidden from plain users", () => { diff --git a/packages/api/src/__tests__/staffing-router.test.ts b/packages/api/src/__tests__/staffing-router.test.ts index 759aa4d..2daeac6 100644 --- a/packages/api/src/__tests__/staffing-router.test.ts +++ b/packages/api/src/__tests__/staffing-router.test.ts @@ -1,5 +1,5 @@ import { listAssignmentBookings } from "@capakraken/application"; -import { SystemRole } from "@capakraken/shared"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; import { staffingRouter } from "../router/staffing.js"; import { createCallerFactory } from "../trpc.js"; @@ -33,6 +33,23 @@ const createCaller = createCallerFactory(staffingRouter); // ── Caller factories ───────────────────────────────────────────────────────── function createProtectedCaller(db: Record) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: { + granted: [PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS], + }, + }, + }); +} + +function createAuthenticatedCaller(db: Record) { return createCaller({ session: { user: { email: "user@example.com", name: "User", image: null }, @@ -47,6 +64,24 @@ function createProtectedCaller(db: Record) { }); } +function createProtectedCallerWithOverrides( + db: Record, + overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null, +) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: overrides, + }, + }); +} + // ── Sample data ────────────────────────────────────────────────────────────── function sampleResource(overrides: Record = {}) { @@ -73,6 +108,164 @@ function sampleResource(overrides: Record = {}) { }; } +describe("staffing router authorization", () => { + const planningWindow = { + resourceId: "res_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-02T00:00:00.000Z"), + }; + const projectRankingWindow = { + projectId: "project_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-02T00:00:00.000Z"), + }; + + it.each([ + { + name: "getSuggestions", + invoke: (caller: ReturnType) => caller.getSuggestions({ + requiredSkills: ["Compositing"], + startDate: planningWindow.startDate, + endDate: planningWindow.endDate, + hoursPerDay: 8, + }), + }, + { + name: "getProjectStaffingSuggestions", + invoke: (caller: ReturnType) => caller.getProjectStaffingSuggestions({ + projectId: projectRankingWindow.projectId, + }), + }, + { + name: "searchCapacity", + invoke: (caller: ReturnType) => caller.searchCapacity({ + startDate: planningWindow.startDate, + endDate: planningWindow.endDate, + }), + }, + { + name: "analyzeUtilization", + invoke: (caller: ReturnType) => caller.analyzeUtilization({ + resourceId: planningWindow.resourceId, + startDate: planningWindow.startDate, + endDate: planningWindow.endDate, + }), + }, + { + name: "findCapacity", + invoke: (caller: ReturnType) => caller.findCapacity({ + resourceId: planningWindow.resourceId, + startDate: planningWindow.startDate, + endDate: planningWindow.endDate, + }), + }, + { + name: "findBestProjectResource", + invoke: (caller: ReturnType) => caller.findBestProjectResource({ + projectId: projectRankingWindow.projectId, + startDate: projectRankingWindow.startDate, + endDate: projectRankingWindow.endDate, + }), + }, + { + name: "getBestProjectResourceDetail", + invoke: (caller: ReturnType) => caller.getBestProjectResourceDetail({ + projectId: projectRankingWindow.projectId, + startDate: projectRankingWindow.startDate, + endDate: projectRankingWindow.endDate, + }), + }, + ])("requires planning read access for $name", async ({ invoke }) => { + const caller = createAuthenticatedCaller({}); + + await expect(invoke(caller)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + }); + + it("allows explicit viewPlanning overrides to search capacity", async () => { + vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]); + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + + const caller = createProtectedCallerWithOverrides(db, { + granted: [PermissionKey.VIEW_PLANNING], + }); + const result = await caller.searchCapacity({ + startDate: planningWindow.startDate, + endDate: planningWindow.endDate, + }); + + expect(result).toEqual({ + period: "2026-04-01 to 2026-04-02", + minHoursFilter: 4, + results: [], + totalFound: 0, + }); + }); + + it("does not treat viewCosts as a substitute for viewPlanning on staffing reads", async () => { + const caller = createProtectedCallerWithOverrides({}, { + granted: [PermissionKey.VIEW_COSTS], + }); + + await expect(caller.searchCapacity({ + startDate: planningWindow.startDate, + endDate: planningWindow.endDate, + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + }); + + it.each([ + { + name: "getSuggestions", + invoke: (caller: ReturnType) => caller.getSuggestions({ + requiredSkills: ["Compositing"], + startDate: planningWindow.startDate, + endDate: planningWindow.endDate, + hoursPerDay: 8, + }), + }, + { + name: "getProjectStaffingSuggestions", + invoke: (caller: ReturnType) => caller.getProjectStaffingSuggestions({ + projectId: projectRankingWindow.projectId, + }), + }, + { + name: "findBestProjectResource", + invoke: (caller: ReturnType) => caller.findBestProjectResource({ + projectId: projectRankingWindow.projectId, + startDate: projectRankingWindow.startDate, + endDate: projectRankingWindow.endDate, + }), + }, + { + name: "getBestProjectResourceDetail", + invoke: (caller: ReturnType) => caller.getBestProjectResourceDetail({ + projectId: projectRankingWindow.projectId, + startDate: projectRankingWindow.startDate, + endDate: projectRankingWindow.endDate, + }), + }, + ])("requires viewCosts for $name", async ({ invoke }) => { + const caller = createProtectedCallerWithOverrides({}, { + granted: [PermissionKey.VIEW_PLANNING], + }); + + await expect(invoke(caller)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: `Permission required: ${PermissionKey.VIEW_COSTS}`, + }); + }); +}); + // ─── getSuggestions ────────────────────────────────────────────────────────── describe("staffing.getSuggestions", () => { diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 89383ea..f32e8a9 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -222,6 +222,7 @@ const COST_TOOLS = new Set([ "get_estimate_detail", "get_estimate_version_snapshot", "find_best_project_resource", + "get_staffing_suggestions", ]); /** Tools that follow planningReadProcedure access rules in the main API. */ @@ -229,6 +230,9 @@ const PLANNING_READ_TOOLS = new Set([ "list_allocations", "list_demands", "check_resource_availability", + "get_staffing_suggestions", + "find_capacity", + "find_best_project_resource", ]); /** Tools that follow controllerProcedure access rules in the main API. */ diff --git a/packages/api/src/router/staffing.ts b/packages/api/src/router/staffing.ts index 5360bce..cc8e1a4 100644 --- a/packages/api/src/router/staffing.ts +++ b/packages/api/src/router/staffing.ts @@ -1,6 +1,6 @@ import { rankResources } from "@capakraken/staffing"; import { listAssignmentBookings } from "@capakraken/application"; -import type { WeekdayAvailability } from "@capakraken/shared"; +import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { @@ -11,7 +11,7 @@ import { type ResourceDailyAvailabilityContext, } from "../lib/resource-capacity.js"; import { fmtEur } from "../lib/format-utils.js"; -import { createTRPCRouter, protectedProcedure } from "../trpc.js"; +import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js"; const DAY_KEYS: (keyof WeekdayAvailability)[] = [ "sunday", @@ -860,7 +860,7 @@ export const staffingRouter = createTRPCRouter({ /** * Get ranked resource suggestions for a staffing requirement. */ - getSuggestions: protectedProcedure + getSuggestions: planningReadProcedure .input( z.object({ requiredSkills: z.array(z.string()), @@ -875,9 +875,12 @@ export const staffingRouter = createTRPCRouter({ minProficiency: z.number().min(1).max(5).optional(), }), ) - .query(async ({ ctx, input }) => queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, input)), + .query(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.VIEW_COSTS); + return queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, input); + }), - getProjectStaffingSuggestions: protectedProcedure + getProjectStaffingSuggestions: planningReadProcedure .input( z.object({ projectId: z.string().min(1), @@ -888,6 +891,7 @@ export const staffingRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.VIEW_COSTS); const project = await findUniqueOrThrow(ctx.db.project.findUnique({ where: { id: input.projectId }, select: { @@ -937,7 +941,7 @@ export const staffingRouter = createTRPCRouter({ }; }), - searchCapacity: protectedProcedure + searchCapacity: planningReadProcedure .input( z.object({ startDate: z.coerce.date(), @@ -1064,7 +1068,7 @@ export const staffingRouter = createTRPCRouter({ /** * Analyze utilization for a specific resource over a date range. */ - analyzeUtilization: protectedProcedure + analyzeUtilization: planningReadProcedure .input( z.object({ resourceId: z.string(), @@ -1179,7 +1183,7 @@ export const staffingRouter = createTRPCRouter({ /** * Find capacity windows for a resource. */ - findCapacity: protectedProcedure + findCapacity: planningReadProcedure .input( z.object({ resourceId: z.string(), @@ -1307,7 +1311,7 @@ export const staffingRouter = createTRPCRouter({ return windows; }), - findBestProjectResource: protectedProcedure + findBestProjectResource: planningReadProcedure .input( z.object({ projectId: z.string(), @@ -1319,9 +1323,12 @@ export const staffingRouter = createTRPCRouter({ roleName: z.string().optional(), }), ) - .query(async ({ ctx, input }) => queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, input)), + .query(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.VIEW_COSTS); + return queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, input); + }), - getBestProjectResourceDetail: protectedProcedure + getBestProjectResourceDetail: planningReadProcedure .input( z.object({ projectId: z.string().min(1), @@ -1335,6 +1342,7 @@ export const staffingRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { + requirePermission(ctx, PermissionKey.VIEW_COSTS); const project = await findUniqueOrThrow(ctx.db.project.findUnique({ where: { id: input.projectId }, select: {