diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 5b196ed..69f8d9c 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -92,14 +92,15 @@ Reasoning: ### `packages/api/src/router/org-unit.ts` -- `list`, `getTree`, `resolveByIdentifier`: `authenticated-safe-lookup` -- `getByIdentifier`, `getById`: `resource-overview` +- `resolveByIdentifier`: `authenticated-safe-lookup` +- `list`, `getTree`, `getByIdentifier`, `getById`: `resource-overview` - create, update, deactivate: `admin-only` Reasoning: -- minimal org-unit lookups are low-risk master data -- detailed org-unit reads expose `_count.resources` and parent/child context that maps the staffing structure +- `resolveByIdentifier` stays narrow enough for low-risk lookup flows +- `list` and especially `getTree` expose the internal org hierarchy, parent links, sort order, and structure metadata, so they should not remain broad authenticated reads +- detailed org-unit reads also expose `_count.resources` and parent/child context that maps the staffing structure ## Assistant Parity Rule diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index 8b0cc5a..c80b41b 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -649,6 +649,7 @@ describe("assistant router tool gating", () => { const managerNames = getToolNames([], SystemRole.MANAGER); const userNames = getToolNames([], SystemRole.USER); const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER); + const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER); expect(adminNames).toContain("list_countries"); expect(adminNames).toContain("create_country"); @@ -668,6 +669,9 @@ describe("assistant router tool gating", () => { expect(userWithResourceOverview).toContain("search_resources"); expect(userWithResourceOverview).toContain("get_country"); expect(userWithResourceOverview).toContain("list_org_units"); + expect(userWithManagedResources).toContain("search_resources"); + expect(userWithManagedResources).toContain("get_country"); + expect(userWithManagedResources).toContain("list_org_units"); }); it("blocks mutation tools until the user confirms a prior assistant summary", () => { 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 8843b0c..76b198a 100644 --- a/packages/api/src/__tests__/master-data-router-auth.test.ts +++ b/packages/api/src/__tests__/master-data-router-auth.test.ts @@ -23,6 +23,33 @@ function createProtectedContext( } describe("master-data router authorization", () => { + it("keeps country lists available to authenticated users as safe lookup data", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: "country_de", + code: "DE", + name: "Germany", + isActive: true, + dailyWorkingHours: 8, + metroCities: [{ id: "city_ber", name: "Berlin", countryId: "country_de" }], + }, + ]); + const caller = createCallerFactory(countryRouter)(createProtectedContext({ + country: { + findMany, + }, + })); + + const result = await caller.list({ isActive: true }); + + expect(result).toHaveLength(1); + expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { isActive: true }, + include: { metroCities: { orderBy: { name: "asc" } } }, + orderBy: { name: "asc" }, + })); + }); + it("keeps minimal country lookups available to authenticated users", async () => { const findFirst = vi.fn().mockResolvedValue({ id: "country_de", @@ -50,6 +77,31 @@ describe("master-data router authorization", () => { expect(findFirst).toHaveBeenCalled(); }); + it("keeps metro-city lookups available to authenticated users", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "city_muc", + name: "Munich", + countryId: "country_de", + }); + const caller = createCallerFactory(countryRouter)(createProtectedContext({ + metroCity: { + findUnique, + }, + })); + + const result = await caller.getCityById({ id: "city_muc" }); + + expect(result).toEqual({ + id: "city_muc", + name: "Munich", + countryId: "country_de", + }); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "city_muc" }, + select: { id: true, name: true, countryId: true }, + }); + }); + it("requires resource overview access for detailed country reads with resource counts", async () => { const caller = createCallerFactory(countryRouter)(createProtectedContext({ country: { @@ -99,6 +151,31 @@ describe("master-data router authorization", () => { })); }); + it("allows detailed country reads for users with manage-resources access", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "country_de", + code: "DE", + name: "Germany", + isActive: true, + dailyWorkingHours: 8, + scheduleRules: null, + metroCities: [], + _count: { resources: 4 }, + }); + const caller = createCallerFactory(countryRouter)(createProtectedContext({ + country: { + findUnique, + }, + }, { + granted: [PermissionKey.MANAGE_RESOURCES], + })); + + const result = await caller.getById({ id: "country_de" }); + + expect(result._count.resources).toBe(4); + expect(findUnique).toHaveBeenCalled(); + }); + it("keeps minimal org-unit lookups available to authenticated users", async () => { const findFirst = vi.fn() .mockResolvedValueOnce(null) @@ -127,6 +204,26 @@ describe("master-data router authorization", () => { }); }); + it("requires resource overview access for org-unit list and tree reads", async () => { + const findMany = vi.fn(); + const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({ + orgUnit: { + findMany, + }, + })); + + await expect(caller.list({ level: 5, isActive: true })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Resource overview access required", + }); + await expect(caller.getTree({ isActive: true })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Resource overview access required", + }); + + expect(findMany).not.toHaveBeenCalled(); + }); + it("requires resource overview access for detailed org-unit reads with staffing counts", async () => { const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({ orgUnit: { @@ -172,4 +269,62 @@ describe("master-data router authorization", () => { include: { _count: { select: { resources: true } } }, })); }); + + it("allows org-unit list and tree reads for users with resource overview access", async () => { + const listFindMany = vi.fn().mockResolvedValue([ + { + id: "ou_1", + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: null, + sortOrder: 10, + isActive: true, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + ]); + const treeFindMany = vi.fn().mockResolvedValue([ + { + id: "ou_1", + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: null, + sortOrder: 10, + isActive: true, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + ]); + const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({ + orgUnit: { + findMany: vi.fn() + .mockImplementationOnce(listFindMany) + .mockImplementationOnce(treeFindMany), + }, + }, { + granted: [PermissionKey.MANAGE_RESOURCES], + })); + + const listResult = await caller.list({ level: 5, isActive: true }); + const treeResult = await caller.getTree({ isActive: true }); + + expect(listResult).toHaveLength(1); + expect(treeResult).toEqual([ + expect.objectContaining({ + id: "ou_1", + name: "Delivery", + children: [], + }), + ]); + expect(listFindMany).toHaveBeenCalledWith({ + where: { level: 5, isActive: true }, + orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }], + }); + expect(treeFindMany).toHaveBeenCalledWith({ + where: { isActive: true }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); + }); }); diff --git a/packages/api/src/router/org-unit.ts b/packages/api/src/router/org-unit.ts index 6def50a..341f8c7 100644 --- a/packages/api/src/router/org-unit.ts +++ b/packages/api/src/router/org-unit.ts @@ -35,7 +35,7 @@ function buildTree(flatItems: FlatOrgUnit[], parentId: string | null = null): Or } export const orgUnitRouter = createTRPCRouter({ - list: protectedProcedure + list: resourceOverviewProcedure .input( z.object({ level: z.number().int().min(5).max(7).optional(), @@ -54,7 +54,7 @@ export const orgUnitRouter = createTRPCRouter({ }); }), - getTree: protectedProcedure + getTree: resourceOverviewProcedure .input(z.object({ isActive: z.boolean().optional() }).optional()) .query(async ({ ctx, input }) => { const all = await ctx.db.orgUnit.findMany({