feat(blueprint): scope summary reads to planning audience
This commit is contained in:
@@ -135,6 +135,19 @@ Reasoning:
|
|||||||
- management-level groups carry chargeability targets and resource-linked counts that feed planning and reporting workflows, so they should not stay on broad authenticated reads
|
- management-level groups carry chargeability targets and resource-linked counts that feed planning and reporting workflows, so they should not stay on broad authenticated reads
|
||||||
- the list is consumed by resource editing, reporting filters, and admin configuration, which all fit the explicit planning audience better than generic `protectedProcedure`
|
- the list is consumed by resource editing, reporting filters, and admin configuration, which all fit the explicit planning audience better than generic `protectedProcedure`
|
||||||
|
|
||||||
|
### `packages/api/src/router/blueprint.ts`
|
||||||
|
|
||||||
|
- `listSummaries`: `planning-read`
|
||||||
|
- `resolveByIdentifier`: `authenticated-safe-lookup`
|
||||||
|
- remaining reads stay unchanged in this rollout
|
||||||
|
- create, update, delete, global-flag writes: `admin-only`
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
|
||||||
|
- `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
|
||||||
|
- broader blueprint read routes still support existing UI flows and need a separate follow-up slice before they can be tightened safely
|
||||||
|
|
||||||
### `packages/api/src/router/holiday-calendar.ts`
|
### `packages/api/src/router/holiday-calendar.ts`
|
||||||
|
|
||||||
- `listCalendars`, `listCalendarsDetail`, `getCalendarByIdentifier`, `getCalendarByIdentifierDetail`, `getCalendarById`: `admin-only`
|
- `listCalendars`, `listCalendarsDetail`, `getCalendarByIdentifier`, `getCalendarByIdentifierDetail`, `getCalendarById`: `admin-only`
|
||||||
|
|||||||
@@ -387,6 +387,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_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");
|
||||||
@@ -397,6 +398,7 @@ describe("assistant router tool gating", () => {
|
|||||||
expect(userWithoutPlanning).not.toContain("find_best_project_resource");
|
expect(userWithoutPlanning).not.toContain("find_best_project_resource");
|
||||||
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_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,5 +1,6 @@
|
|||||||
import { PermissionKey, SystemRole } from "@capakraken/shared";
|
import { 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 { clientRouter } from "../router/client.js";
|
import { clientRouter } from "../router/client.js";
|
||||||
import { countryRouter } from "../router/country.js";
|
import { countryRouter } from "../router/country.js";
|
||||||
import { managementLevelRouter } from "../router/management-level.js";
|
import { managementLevelRouter } from "../router/management-level.js";
|
||||||
@@ -26,6 +27,52 @@ function createProtectedContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("master-data router authorization", () => {
|
describe("master-data router authorization", () => {
|
||||||
|
it("requires planning read access for blueprint summaries with project counts", async () => {
|
||||||
|
const findMany = vi.fn();
|
||||||
|
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
|
||||||
|
blueprint: {
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(caller.listSummaries()).rejects.toMatchObject({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Planning read access required",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows blueprint summaries for users with planning access", async () => {
|
||||||
|
const findMany = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "bp_1",
|
||||||
|
name: "Consulting Blueprint",
|
||||||
|
_count: { projects: 4 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const caller = createCallerFactory(blueprintRouter)(createProtectedContext({
|
||||||
|
blueprint: {
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
granted: [PermissionKey.VIEW_PLANNING],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await caller.listSummaries();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?._count.projects).toBe(4);
|
||||||
|
expect(findMany).toHaveBeenCalledWith({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
_count: { select: { projects: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ const COST_TOOLS = new Set([
|
|||||||
const PLANNING_READ_TOOLS = new Set([
|
const PLANNING_READ_TOOLS = new Set([
|
||||||
"list_allocations",
|
"list_allocations",
|
||||||
"list_demands",
|
"list_demands",
|
||||||
|
"list_blueprints",
|
||||||
"list_clients",
|
"list_clients",
|
||||||
"list_roles",
|
"list_roles",
|
||||||
"list_management_levels",
|
"list_management_levels",
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema, type Blu
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { adminProcedure, createTRPCRouter, planningReadProcedure, protectedProcedure } from "../trpc.js";
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
|
|
||||||
export const blueprintRouter = createTRPCRouter({
|
export const blueprintRouter = createTRPCRouter({
|
||||||
listSummaries: protectedProcedure
|
listSummaries: planningReadProcedure
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return ctx.db.blueprint.findMany({
|
return ctx.db.blueprint.findMany({
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
Reference in New Issue
Block a user