diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index d4cc1e7..19bccf5 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -146,7 +146,7 @@ Reasoning: ### `packages/api/src/router/blueprint.ts` -- `listSummaries`, `list`, `getById`, `getByIdentifier`: `planning-read` +- `listSummaries`, `list`, `getById`, `getByIdentifier`, `getGlobalFieldDefs`: `planning-read` - `resolveByIdentifier`: `authenticated-safe-lookup` - create, update, delete, global-flag writes: `admin-only` @@ -155,6 +155,7 @@ 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 - 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 +- `getGlobalFieldDefs` aggregates active global field definitions across blueprints, so it belongs with the same planning configuration audience rather than a broad authenticated read ### `packages/api/src/router/holiday-calendar.ts` 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 79970d8..37aac83 100644 --- a/packages/api/src/__tests__/master-data-router-auth.test.ts +++ b/packages/api/src/__tests__/master-data-router-auth.test.ts @@ -26,6 +26,14 @@ function createProtectedContext( }; } +function createUnauthenticatedContext(db: Record) { + return { + session: null, + db: db as never, + dbUser: null, + }; +} + describe("master-data router authorization", () => { it("requires planning read access for blueprint summaries with project counts", async () => { const findMany = vi.fn(); @@ -166,6 +174,75 @@ describe("master-data router authorization", () => { }); }); + it("requires authenticated planning access for global blueprint field definitions", async () => { + const findMany = vi.fn(); + + const unauthenticatedCaller = createCallerFactory(blueprintRouter)(createUnauthenticatedContext({ + blueprint: { + findMany, + }, + })); + const authenticatedCaller = createCallerFactory(blueprintRouter)(createProtectedContext({ + blueprint: { + findMany, + }, + })); + + await expect(unauthenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + await expect(authenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + + expect(findMany).not.toHaveBeenCalled(); + }); + + it("allows global blueprint field definitions for users with planning access", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: "bp_project_global", + name: "Global Project Blueprint", + fieldDefs: [ + { key: "market", label: "Market", type: "text" }, + { key: "deliveryModel", label: "Delivery Model", type: "select" }, + ], + }, + ]); + const caller = createCallerFactory(blueprintRouter)(createProtectedContext({ + blueprint: { + findMany, + }, + }, { + granted: [PermissionKey.VIEW_PLANNING], + })); + + const result = await caller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT }); + + expect(result).toEqual([ + { + key: "market", + label: "Market", + type: "text", + blueprintId: "bp_project_global", + blueprintName: "Global Project Blueprint", + }, + { + key: "deliveryModel", + label: "Delivery Model", + type: "select", + blueprintId: "bp_project_global", + blueprintName: "Global Project Blueprint", + }, + ]); + expect(findMany).toHaveBeenCalledWith({ + where: { target: "PROJECT", isGlobal: true, isActive: true }, + select: { id: true, name: true, fieldDefs: true }, + }); + }); + 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/blueprint.ts b/packages/api/src/router/blueprint.ts index 44d2afa..ce00c68 100644 --- a/packages/api/src/router/blueprint.ts +++ b/packages/api/src/router/blueprint.ts @@ -247,7 +247,7 @@ export const blueprintRouter = createTRPCRouter({ return { count: updated.length }; }), - getGlobalFieldDefs: protectedProcedure + getGlobalFieldDefs: planningReadProcedure .input(z.object({ target: z.nativeEnum(BlueprintTarget) })) .query(async ({ ctx, input }) => { const blueprints = await ctx.db.blueprint.findMany({