fix(blueprint): require planning access for global field defs
This commit is contained in:
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user