fix(blueprint): require planning access for detailed reads
This commit is contained in:
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user