diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 872cc18..81bdce9 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -146,16 +146,15 @@ Reasoning: ### `packages/api/src/router/blueprint.ts` -- `listSummaries`: `planning-read` +- `listSummaries`, `list`, `getById`, `getByIdentifier`: `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 +- the broader blueprint reads expose full template configuration such as field definitions, defaults, and validation rules that belong to planning workflows rather than generic authenticated access ### `packages/api/src/router/holiday-calendar.ts` diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index d6ea721..e39ca42 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -391,6 +391,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("get_blueprint"); expect(userWithoutPlanning).not.toContain("list_clients"); expect(userWithoutPlanning).not.toContain("list_roles"); expect(userWithoutPlanning).not.toContain("list_management_levels"); @@ -402,6 +403,7 @@ describe("assistant router tool gating", () => { expect(userWithPlanning).toContain("list_allocations"); expect(userWithPlanning).toContain("list_demands"); expect(userWithPlanning).toContain("list_blueprints"); + expect(userWithPlanning).toContain("get_blueprint"); 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 075d4a8..79970d8 100644 --- a/packages/api/src/__tests__/master-data-router-auth.test.ts +++ b/packages/api/src/__tests__/master-data-router-auth.test.ts @@ -1,4 +1,4 @@ -import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { BlueprintTarget, PermissionKey, SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; import { blueprintRouter } from "../router/blueprint.js"; import { clientRouter } from "../router/client.js"; @@ -73,6 +73,99 @@ describe("master-data router authorization", () => { }); }); + it("requires planning read access for blueprint list and detailed reads", async () => { + const findMany = vi.fn(); + const findUnique = vi.fn(); + const findFirst = vi.fn(); + const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ + blueprint: { + findMany, + findUnique, + findFirst, + }, + })); + + await expect(caller.list({ isActive: true })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + await expect(caller.getByIdentifier({ identifier: "Consulting Blueprint" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + await expect(caller.getById({ id: "bp_1" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + + expect(findMany).not.toHaveBeenCalled(); + expect(findUnique).not.toHaveBeenCalled(); + expect(findFirst).not.toHaveBeenCalled(); + }); + + it("allows blueprint list and detailed reads for users with planning access", async () => { + const listFindMany = vi.fn().mockResolvedValue([ + { + id: "bp_1", + name: "Consulting Blueprint", + target: "PROJECT", + description: "Default consulting setup", + isActive: true, + }, + ]); + const getByIdFindUnique = vi.fn() + .mockResolvedValueOnce({ + id: "bp_1", + name: "Consulting Blueprint", + target: "PROJECT", + description: "Default consulting setup", + fieldDefs: [{ key: "market", type: "text" }], + defaults: { market: "EU" }, + validationRules: [], + rolePresets: [], + isActive: true, + }) + .mockResolvedValueOnce(null); + const getByIdentifierFindFirst = vi.fn().mockResolvedValue({ + id: "bp_1", + name: "Consulting Blueprint", + target: "PROJECT", + description: "Default consulting setup", + fieldDefs: [{ key: "market", type: "text" }], + defaults: { market: "EU" }, + validationRules: [], + rolePresets: [], + isActive: true, + }); + const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ + blueprint: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + findFirst: getByIdentifierFindFirst, + }, + }, { + granted: [PermissionKey.VIEW_PLANNING], + })); + + const listResult = await caller.list({ target: BlueprintTarget.PROJECT, isActive: true }); + const byIdResult = await caller.getById({ id: "bp_1" }); + const byIdentifierResult = await caller.getByIdentifier({ identifier: "Consulting Blueprint" }); + + expect(listResult).toHaveLength(1); + expect(byIdResult.name).toBe("Consulting Blueprint"); + expect(byIdentifierResult.fieldDefs).toEqual([{ key: "market", type: "text" }]); + expect(listFindMany).toHaveBeenCalledWith({ + where: { target: "PROJECT", isActive: true }, + orderBy: { name: "asc" }, + }); + expect(getByIdFindUnique).toHaveBeenCalledWith({ + where: { id: "bp_1" }, + }); + expect(getByIdentifierFindFirst).toHaveBeenCalledWith({ + where: { name: { equals: "Consulting Blueprint", mode: "insensitive" } }, + }); + }); + 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 13ca0a1..86992f8 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -230,6 +230,7 @@ const PLANNING_READ_TOOLS = new Set([ "list_allocations", "list_demands", "list_blueprints", + "get_blueprint", "list_clients", "list_roles", "list_management_levels", diff --git a/packages/api/src/router/blueprint.ts b/packages/api/src/router/blueprint.ts index 5066581..44d2afa 100644 --- a/packages/api/src/router/blueprint.ts +++ b/packages/api/src/router/blueprint.ts @@ -18,7 +18,7 @@ export const blueprintRouter = createTRPCRouter({ }); }), - list: protectedProcedure + list: planningReadProcedure .input( z.object({ target: z.nativeEnum(BlueprintTarget).optional(), @@ -35,7 +35,7 @@ export const blueprintRouter = createTRPCRouter({ }); }), - getById: protectedProcedure + getById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const blueprint = await findUniqueOrThrow( @@ -82,7 +82,7 @@ export const blueprintRouter = createTRPCRouter({ return blueprint; }), - getByIdentifier: protectedProcedure + getByIdentifier: planningReadProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { const identifier = input.identifier.trim();