feat(org-unit): scope structural reads to resource overview

This commit is contained in:
2026-03-30 10:17:57 +02:00
parent 65fe7ce04f
commit 2b514ea962
4 changed files with 166 additions and 6 deletions
+5 -4
View File
@@ -92,14 +92,15 @@ Reasoning:
### `packages/api/src/router/org-unit.ts` ### `packages/api/src/router/org-unit.ts`
- `list`, `getTree`, `resolveByIdentifier`: `authenticated-safe-lookup` - `resolveByIdentifier`: `authenticated-safe-lookup`
- `getByIdentifier`, `getById`: `resource-overview` - `list`, `getTree`, `getByIdentifier`, `getById`: `resource-overview`
- create, update, deactivate: `admin-only` - create, update, deactivate: `admin-only`
Reasoning: Reasoning:
- minimal org-unit lookups are low-risk master data - `resolveByIdentifier` stays narrow enough for low-risk lookup flows
- detailed org-unit reads expose `_count.resources` and parent/child context that maps the staffing structure - `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 ## Assistant Parity Rule
@@ -649,6 +649,7 @@ describe("assistant router tool gating", () => {
const managerNames = getToolNames([], SystemRole.MANAGER); const managerNames = getToolNames([], SystemRole.MANAGER);
const userNames = getToolNames([], SystemRole.USER); const userNames = getToolNames([], SystemRole.USER);
const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], 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("list_countries");
expect(adminNames).toContain("create_country"); expect(adminNames).toContain("create_country");
@@ -668,6 +669,9 @@ describe("assistant router tool gating", () => {
expect(userWithResourceOverview).toContain("search_resources"); expect(userWithResourceOverview).toContain("search_resources");
expect(userWithResourceOverview).toContain("get_country"); expect(userWithResourceOverview).toContain("get_country");
expect(userWithResourceOverview).toContain("list_org_units"); 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", () => { it("blocks mutation tools until the user confirms a prior assistant summary", () => {
@@ -23,6 +23,33 @@ function createProtectedContext(
} }
describe("master-data router authorization", () => { 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 () => { it("keeps minimal country lookups available to authenticated users", async () => {
const findFirst = vi.fn().mockResolvedValue({ const findFirst = vi.fn().mockResolvedValue({
id: "country_de", id: "country_de",
@@ -50,6 +77,31 @@ describe("master-data router authorization", () => {
expect(findFirst).toHaveBeenCalled(); 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 () => { it("requires resource overview access for detailed country reads with resource counts", async () => {
const caller = createCallerFactory(countryRouter)(createProtectedContext({ const caller = createCallerFactory(countryRouter)(createProtectedContext({
country: { 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 () => { it("keeps minimal org-unit lookups available to authenticated users", async () => {
const findFirst = vi.fn() const findFirst = vi.fn()
.mockResolvedValueOnce(null) .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 () => { it("requires resource overview access for detailed org-unit reads with staffing counts", async () => {
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({ const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
orgUnit: { orgUnit: {
@@ -172,4 +269,62 @@ describe("master-data router authorization", () => {
include: { _count: { select: { resources: true } } }, 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" }],
});
});
}); });
+2 -2
View File
@@ -35,7 +35,7 @@ function buildTree(flatItems: FlatOrgUnit[], parentId: string | null = null): Or
} }
export const orgUnitRouter = createTRPCRouter({ export const orgUnitRouter = createTRPCRouter({
list: protectedProcedure list: resourceOverviewProcedure
.input( .input(
z.object({ z.object({
level: z.number().int().min(5).max(7).optional(), 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()) .input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const all = await ctx.db.orgUnit.findMany({ const all = await ctx.db.orgUnit.findMany({