diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index b7c3235..cac59e2 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -135,6 +135,19 @@ Reasoning: - management-level groups carry chargeability targets and resource-linked counts that feed planning and reporting workflows, so they should not stay on broad authenticated reads - the list is consumed by resource editing, reporting filters, and admin configuration, which all fit the explicit planning audience better than generic `protectedProcedure` +### `packages/api/src/router/blueprint.ts` + +- `listSummaries`: `planning-read` +- `resolveByIdentifier`: `authenticated-safe-lookup` +- remaining reads stay unchanged in this rollout +- create, update, delete, global-flag writes: `admin-only` + +Reasoning: + +- `listSummaries` exposes `_count.projects`, so the assistant-facing summary list should not remain a broad authenticated read +- `resolveByIdentifier` already returns a narrow lookup shape suitable for low-risk name/id resolution +- broader blueprint read routes still support existing UI flows and need a separate follow-up slice before they can be tightened safely + ### `packages/api/src/router/holiday-calendar.ts` - `listCalendars`, `listCalendarsDetail`, `getCalendarByIdentifier`, `getCalendarByIdentifierDetail`, `getCalendarById`: `admin-only` diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index e37c76e..88c7c95 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -387,6 +387,7 @@ describe("assistant router tool gating", () => { expect(userWithoutPlanning).not.toContain("list_allocations"); expect(userWithoutPlanning).not.toContain("list_demands"); + expect(userWithoutPlanning).not.toContain("list_blueprints"); expect(userWithoutPlanning).not.toContain("list_clients"); expect(userWithoutPlanning).not.toContain("list_roles"); expect(userWithoutPlanning).not.toContain("list_management_levels"); @@ -397,6 +398,7 @@ describe("assistant router tool gating", () => { expect(userWithoutPlanning).not.toContain("find_best_project_resource"); expect(userWithPlanning).toContain("list_allocations"); expect(userWithPlanning).toContain("list_demands"); + expect(userWithPlanning).toContain("list_blueprints"); expect(userWithPlanning).toContain("list_clients"); expect(userWithPlanning).toContain("list_roles"); expect(userWithPlanning).toContain("list_management_levels"); diff --git a/packages/api/src/__tests__/master-data-router-auth.test.ts b/packages/api/src/__tests__/master-data-router-auth.test.ts index 3ade992..075d4a8 100644 --- a/packages/api/src/__tests__/master-data-router-auth.test.ts +++ b/packages/api/src/__tests__/master-data-router-auth.test.ts @@ -1,5 +1,6 @@ import { PermissionKey, SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; +import { blueprintRouter } from "../router/blueprint.js"; import { clientRouter } from "../router/client.js"; import { countryRouter } from "../router/country.js"; import { managementLevelRouter } from "../router/management-level.js"; @@ -26,6 +27,52 @@ function createProtectedContext( } describe("master-data router authorization", () => { + it("requires planning read access for blueprint summaries with project counts", async () => { + const findMany = vi.fn(); + const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ + blueprint: { + findMany, + }, + })); + + await expect(caller.listSummaries()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + + expect(findMany).not.toHaveBeenCalled(); + }); + + it("allows blueprint summaries for users with planning access", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: "bp_1", + name: "Consulting Blueprint", + _count: { projects: 4 }, + }, + ]); + const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ + blueprint: { + findMany, + }, + }, { + granted: [PermissionKey.VIEW_PLANNING], + })); + + const result = await caller.listSummaries(); + + expect(result).toHaveLength(1); + expect(result[0]?._count.projects).toBe(4); + expect(findMany).toHaveBeenCalledWith({ + select: { + id: true, + name: true, + _count: { select: { projects: true } }, + }, + orderBy: { name: "asc" }, + }); + }); + it("keeps country lists available to authenticated users as safe lookup data", async () => { const findMany = vi.fn().mockResolvedValue([ { diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 581e0b7..166e226 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -228,6 +228,7 @@ const COST_TOOLS = new Set([ const PLANNING_READ_TOOLS = new Set([ "list_allocations", "list_demands", + "list_blueprints", "list_clients", "list_roles", "list_management_levels", diff --git a/packages/api/src/router/blueprint.ts b/packages/api/src/router/blueprint.ts index 3ccf1df..5066581 100644 --- a/packages/api/src/router/blueprint.ts +++ b/packages/api/src/router/blueprint.ts @@ -2,11 +2,11 @@ import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema, type Blu import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; -import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; +import { adminProcedure, createTRPCRouter, planningReadProcedure, protectedProcedure } from "../trpc.js"; import { createAuditEntry } from "../lib/audit.js"; export const blueprintRouter = createTRPCRouter({ - listSummaries: protectedProcedure + listSummaries: planningReadProcedure .query(async ({ ctx }) => { return ctx.db.blueprint.findMany({ select: {