fix(blueprint): require planning access for global field defs

This commit is contained in:
2026-03-30 12:18:59 +02:00
parent 649c8feb22
commit c9a35452dc
3 changed files with 80 additions and 2 deletions
+2 -1
View File
@@ -146,7 +146,7 @@ Reasoning:
### `packages/api/src/router/blueprint.ts` ### `packages/api/src/router/blueprint.ts`
- `listSummaries`, `list`, `getById`, `getByIdentifier`: `planning-read` - `listSummaries`, `list`, `getById`, `getByIdentifier`, `getGlobalFieldDefs`: `planning-read`
- `resolveByIdentifier`: `authenticated-safe-lookup` - `resolveByIdentifier`: `authenticated-safe-lookup`
- create, update, delete, global-flag writes: `admin-only` - create, update, delete, global-flag writes: `admin-only`
@@ -155,6 +155,7 @@ 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
- 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 - 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
- `getGlobalFieldDefs` aggregates active global field definitions across blueprints, so it belongs with the same planning configuration audience rather than a broad authenticated read
### `packages/api/src/router/holiday-calendar.ts` ### `packages/api/src/router/holiday-calendar.ts`
@@ -26,6 +26,14 @@ function createProtectedContext(
}; };
} }
function createUnauthenticatedContext(db: Record<string, unknown>) {
return {
session: null,
db: db as never,
dbUser: null,
};
}
describe("master-data router authorization", () => { describe("master-data router authorization", () => {
it("requires planning read access for blueprint summaries with project counts", async () => { it("requires planning read access for blueprint summaries with project counts", async () => {
const findMany = vi.fn(); const findMany = vi.fn();
@@ -166,6 +174,75 @@ describe("master-data router authorization", () => {
}); });
}); });
it("requires authenticated planning access for global blueprint field definitions", async () => {
const findMany = vi.fn();
const unauthenticatedCaller = createCallerFactory(blueprintRouter)(createUnauthenticatedContext({
blueprint: {
findMany,
},
}));
const authenticatedCaller = createCallerFactory(blueprintRouter)(createProtectedContext({
blueprint: {
findMany,
},
}));
await expect(unauthenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT })).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
await expect(authenticatedCaller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
expect(findMany).not.toHaveBeenCalled();
});
it("allows global blueprint field definitions for users with planning access", async () => {
const findMany = vi.fn().mockResolvedValue([
{
id: "bp_project_global",
name: "Global Project Blueprint",
fieldDefs: [
{ key: "market", label: "Market", type: "text" },
{ key: "deliveryModel", label: "Delivery Model", type: "select" },
],
},
]);
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
blueprint: {
findMany,
},
}, {
granted: [PermissionKey.VIEW_PLANNING],
}));
const result = await caller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT });
expect(result).toEqual([
{
key: "market",
label: "Market",
type: "text",
blueprintId: "bp_project_global",
blueprintName: "Global Project Blueprint",
},
{
key: "deliveryModel",
label: "Delivery Model",
type: "select",
blueprintId: "bp_project_global",
blueprintName: "Global Project Blueprint",
},
]);
expect(findMany).toHaveBeenCalledWith({
where: { target: "PROJECT", isGlobal: true, isActive: true },
select: { id: true, name: true, fieldDefs: true },
});
});
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 -1
View File
@@ -247,7 +247,7 @@ export const blueprintRouter = createTRPCRouter({
return { count: updated.length }; return { count: updated.length };
}), }),
getGlobalFieldDefs: protectedProcedure getGlobalFieldDefs: planningReadProcedure
.input(z.object({ target: z.nativeEnum(BlueprintTarget) })) .input(z.object({ target: z.nativeEnum(BlueprintTarget) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const blueprints = await ctx.db.blueprint.findMany({ const blueprints = await ctx.db.blueprint.findMany({