fix(blueprint): require planning access for detailed reads

This commit is contained in:
2026-03-30 11:55:43 +02:00
parent 7aa32f8a5c
commit 3a29ce4332
5 changed files with 102 additions and 7 deletions
+2 -3
View File
@@ -146,16 +146,15 @@ Reasoning:
### `packages/api/src/router/blueprint.ts` ### `packages/api/src/router/blueprint.ts`
- `listSummaries`: `planning-read` - `listSummaries`, `list`, `getById`, `getByIdentifier`: `planning-read`
- `resolveByIdentifier`: `authenticated-safe-lookup` - `resolveByIdentifier`: `authenticated-safe-lookup`
- remaining reads stay unchanged in this rollout
- create, update, delete, global-flag writes: `admin-only` - create, update, delete, global-flag writes: `admin-only`
Reasoning: Reasoning:
- `listSummaries` exposes `_count.projects`, so the assistant-facing summary list should not remain a broad authenticated read - `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 - `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` ### `packages/api/src/router/holiday-calendar.ts`
@@ -391,6 +391,7 @@ describe("assistant router tool gating", () => {
expect(userWithoutPlanning).not.toContain("list_allocations"); expect(userWithoutPlanning).not.toContain("list_allocations");
expect(userWithoutPlanning).not.toContain("list_demands"); expect(userWithoutPlanning).not.toContain("list_demands");
expect(userWithoutPlanning).not.toContain("list_blueprints"); expect(userWithoutPlanning).not.toContain("list_blueprints");
expect(userWithoutPlanning).not.toContain("get_blueprint");
expect(userWithoutPlanning).not.toContain("list_clients"); expect(userWithoutPlanning).not.toContain("list_clients");
expect(userWithoutPlanning).not.toContain("list_roles"); expect(userWithoutPlanning).not.toContain("list_roles");
expect(userWithoutPlanning).not.toContain("list_management_levels"); 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_allocations");
expect(userWithPlanning).toContain("list_demands"); expect(userWithPlanning).toContain("list_demands");
expect(userWithPlanning).toContain("list_blueprints"); expect(userWithPlanning).toContain("list_blueprints");
expect(userWithPlanning).toContain("get_blueprint");
expect(userWithPlanning).toContain("list_clients"); expect(userWithPlanning).toContain("list_clients");
expect(userWithPlanning).toContain("list_roles"); expect(userWithPlanning).toContain("list_roles");
expect(userWithPlanning).toContain("list_management_levels"); expect(userWithPlanning).toContain("list_management_levels");
@@ -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 { describe, expect, it, vi } from "vitest";
import { blueprintRouter } from "../router/blueprint.js"; import { blueprintRouter } from "../router/blueprint.js";
import { clientRouter } from "../router/client.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 () => { it("keeps country lists available to authenticated users as safe lookup data", async () => {
const findMany = vi.fn().mockResolvedValue([ const findMany = vi.fn().mockResolvedValue([
{ {
+1
View File
@@ -230,6 +230,7 @@ const PLANNING_READ_TOOLS = new Set([
"list_allocations", "list_allocations",
"list_demands", "list_demands",
"list_blueprints", "list_blueprints",
"get_blueprint",
"list_clients", "list_clients",
"list_roles", "list_roles",
"list_management_levels", "list_management_levels",
+3 -3
View File
@@ -18,7 +18,7 @@ export const blueprintRouter = createTRPCRouter({
}); });
}), }),
list: protectedProcedure list: planningReadProcedure
.input( .input(
z.object({ z.object({
target: z.nativeEnum(BlueprintTarget).optional(), target: z.nativeEnum(BlueprintTarget).optional(),
@@ -35,7 +35,7 @@ export const blueprintRouter = createTRPCRouter({
}); });
}), }),
getById: protectedProcedure getById: planningReadProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const blueprint = await findUniqueOrThrow( const blueprint = await findUniqueOrThrow(
@@ -82,7 +82,7 @@ export const blueprintRouter = createTRPCRouter({
return blueprint; return blueprint;
}), }),
getByIdentifier: protectedProcedure getByIdentifier: planningReadProcedure
.input(z.object({ identifier: z.string().trim().min(1) })) .input(z.object({ identifier: z.string().trim().min(1) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const identifier = input.identifier.trim(); const identifier = input.identifier.trim();