From 9b764008c3efb8a58c44ae4c91dfd8b7e5a64d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 10:45:44 +0200 Subject: [PATCH] feat(management-level): scope reads to planning audience --- docs/route-access-matrix.md | 10 +++ .../src/__tests__/assistant-router.test.ts | 2 + .../__tests__/master-data-router-auth.test.ts | 69 +++++++++++++++++++ packages/api/src/router/assistant.ts | 1 + packages/api/src/router/management-level.ts | 6 +- 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 18803e7..b7c3235 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -125,6 +125,16 @@ Reasoning: - the categories feed project configuration and planning/reporting workflows instead of broad self-service screens - `getById` includes `_count.projects`, so the detailed read should not remain a generic authenticated route +### `packages/api/src/router/management-level.ts` + +- `listGroups`, `getGroupById`: `planning-read` +- create, update, delete: `admin-only` + +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/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 63f7863..e37c76e 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -389,6 +389,7 @@ describe("assistant router tool gating", () => { expect(userWithoutPlanning).not.toContain("list_demands"); expect(userWithoutPlanning).not.toContain("list_clients"); expect(userWithoutPlanning).not.toContain("list_roles"); + expect(userWithoutPlanning).not.toContain("list_management_levels"); expect(userWithoutPlanning).not.toContain("list_utilization_categories"); expect(userWithoutPlanning).not.toContain("check_resource_availability"); expect(userWithoutPlanning).not.toContain("find_capacity"); @@ -398,6 +399,7 @@ describe("assistant router tool gating", () => { expect(userWithPlanning).toContain("list_demands"); expect(userWithPlanning).toContain("list_clients"); expect(userWithPlanning).toContain("list_roles"); + expect(userWithPlanning).toContain("list_management_levels"); expect(userWithPlanning).toContain("list_utilization_categories"); expect(userWithPlanning).toContain("check_resource_availability"); expect(userWithPlanning).toContain("find_capacity"); 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 0f7be44..3ade992 100644 --- a/packages/api/src/__tests__/master-data-router-auth.test.ts +++ b/packages/api/src/__tests__/master-data-router-auth.test.ts @@ -2,6 +2,7 @@ import { PermissionKey, SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; import { clientRouter } from "../router/client.js"; import { countryRouter } from "../router/country.js"; +import { managementLevelRouter } from "../router/management-level.js"; import { orgUnitRouter } from "../router/org-unit.js"; import { utilizationCategoryRouter } from "../router/utilization-category.js"; import { createCallerFactory } from "../trpc.js"; @@ -576,4 +577,72 @@ describe("master-data router authorization", () => { include: { _count: { select: { projects: true } } }, }); }); + + it("requires planning read access for management-level reads", async () => { + const listFindMany = vi.fn(); + const getByIdFindUnique = vi.fn(); + const caller = createCallerFactory(managementLevelRouter)(createProtectedContext({ + managementLevelGroup: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + }, + })); + + await expect(caller.listGroups()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + await expect(caller.getGroupById({ id: "mgmt_group_1" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + + expect(listFindMany).not.toHaveBeenCalled(); + expect(getByIdFindUnique).not.toHaveBeenCalled(); + }); + + it("allows management-level reads for users with planning access", async () => { + const listFindMany = vi.fn().mockResolvedValue([ + { + id: "mgmt_group_1", + name: "Team Leads", + targetPercentage: 0.72, + sortOrder: 10, + levels: [{ id: "mgmt_level_1", name: "Senior Team Lead" }], + }, + ]); + const getByIdFindUnique = vi.fn().mockResolvedValue({ + id: "mgmt_group_1", + name: "Team Leads", + targetPercentage: 0.72, + sortOrder: 10, + levels: [{ id: "mgmt_level_1", name: "Senior Team Lead" }], + _count: { resources: 6 }, + }); + const caller = createCallerFactory(managementLevelRouter)(createProtectedContext({ + managementLevelGroup: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + }, + }, { + granted: [PermissionKey.VIEW_PLANNING], + })); + + const listResult = await caller.listGroups(); + const detailResult = await caller.getGroupById({ id: "mgmt_group_1" }); + + expect(listResult).toHaveLength(1); + expect(detailResult._count.resources).toBe(6); + expect(listFindMany).toHaveBeenCalledWith({ + include: { levels: { orderBy: { name: "asc" } } }, + orderBy: { sortOrder: "asc" }, + }); + expect(getByIdFindUnique).toHaveBeenCalledWith({ + where: { id: "mgmt_group_1" }, + include: { + levels: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }); + }); }); diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 1fe525c..581e0b7 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_demands", "list_clients", "list_roles", + "list_management_levels", "list_utilization_categories", "check_resource_availability", "get_staffing_suggestions", diff --git a/packages/api/src/router/management-level.ts b/packages/api/src/router/management-level.ts index 7b35704..4f79340 100644 --- a/packages/api/src/router/management-level.ts +++ b/packages/api/src/router/management-level.ts @@ -8,19 +8,19 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; -import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; +import { adminProcedure, createTRPCRouter, planningReadProcedure } from "../trpc.js"; export const managementLevelRouter = createTRPCRouter({ // ─── Groups ───────────────────────────────────────────── - listGroups: protectedProcedure.query(async ({ ctx }) => { + listGroups: planningReadProcedure.query(async ({ ctx }) => { return ctx.db.managementLevelGroup.findMany({ include: { levels: { orderBy: { name: "asc" } } }, orderBy: { sortOrder: "asc" }, }); }), - getGroupById: protectedProcedure + getGroupById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const group = await findUniqueOrThrow(