feat(staffing): enforce planning and cost audiences

This commit is contained in:
2026-03-30 09:36:38 +02:00
parent a960d43ed1
commit 3aac946443
4 changed files with 248 additions and 13 deletions
@@ -208,8 +208,12 @@ describe("assistant router tool gating", () => {
}); });
it("hides advanced tools unless the dedicated assistant permission is granted", () => { it("hides advanced tools unless the dedicated assistant permission is granted", () => {
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]); const withoutAdvanced = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
]);
const withAdvanced = getToolNames([ const withAdvanced = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS, PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
]); ]);
@@ -382,9 +386,35 @@ 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("check_resource_availability"); expect(userWithoutPlanning).not.toContain("check_resource_availability");
expect(userWithoutPlanning).not.toContain("find_capacity");
expect(userWithoutPlanning).not.toContain("get_staffing_suggestions");
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("check_resource_availability"); expect(userWithPlanning).toContain("check_resource_availability");
expect(userWithPlanning).toContain("find_capacity");
expect(userWithPlanning).not.toContain("get_staffing_suggestions");
expect(userWithPlanning).not.toContain("find_best_project_resource");
});
it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => {
const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
const planningAndCosts = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
], SystemRole.USER);
const planningCostsAndAdvanced = getToolNames([
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
], SystemRole.USER);
expect(planningOnly).not.toContain("get_staffing_suggestions");
expect(planningOnly).not.toContain("find_best_project_resource");
expect(planningAndCosts).toContain("get_staffing_suggestions");
expect(planningAndCosts).not.toContain("find_best_project_resource");
expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions");
expect(planningCostsAndAdvanced).toContain("find_best_project_resource");
}); });
it("keeps controller-only project and dashboard reads hidden from plain users", () => { it("keeps controller-only project and dashboard reads hidden from plain users", () => {
@@ -1,5 +1,5 @@
import { listAssignmentBookings } from "@capakraken/application"; import { listAssignmentBookings } from "@capakraken/application";
import { 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 { staffingRouter } from "../router/staffing.js"; import { staffingRouter } from "../router/staffing.js";
import { createCallerFactory } from "../trpc.js"; import { createCallerFactory } from "../trpc.js";
@@ -33,6 +33,23 @@ const createCaller = createCallerFactory(staffingRouter);
// ── Caller factories ───────────────────────────────────────────────────────── // ── Caller factories ─────────────────────────────────────────────────────────
function createProtectedCaller(db: Record<string, unknown>) { function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: {
granted: [PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS],
},
},
});
}
function createAuthenticatedCaller(db: Record<string, unknown>) {
return createCaller({ return createCaller({
session: { session: {
user: { email: "user@example.com", name: "User", image: null }, user: { email: "user@example.com", name: "User", image: null },
@@ -47,6 +64,24 @@ function createProtectedCaller(db: Record<string, unknown>) {
}); });
} }
function createProtectedCallerWithOverrides(
db: Record<string, unknown>,
overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null,
) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: overrides,
},
});
}
// ── Sample data ────────────────────────────────────────────────────────────── // ── Sample data ──────────────────────────────────────────────────────────────
function sampleResource(overrides: Record<string, unknown> = {}) { function sampleResource(overrides: Record<string, unknown> = {}) {
@@ -73,6 +108,164 @@ function sampleResource(overrides: Record<string, unknown> = {}) {
}; };
} }
describe("staffing router authorization", () => {
const planningWindow = {
resourceId: "res_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
};
const projectRankingWindow = {
projectId: "project_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
};
it.each([
{
name: "getSuggestions",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
hoursPerDay: 8,
}),
},
{
name: "getProjectStaffingSuggestions",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.getProjectStaffingSuggestions({
projectId: projectRankingWindow.projectId,
}),
},
{
name: "searchCapacity",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.searchCapacity({
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
}),
},
{
name: "analyzeUtilization",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.analyzeUtilization({
resourceId: planningWindow.resourceId,
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
}),
},
{
name: "findCapacity",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.findCapacity({
resourceId: planningWindow.resourceId,
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
}),
},
{
name: "findBestProjectResource",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.findBestProjectResource({
projectId: projectRankingWindow.projectId,
startDate: projectRankingWindow.startDate,
endDate: projectRankingWindow.endDate,
}),
},
{
name: "getBestProjectResourceDetail",
invoke: (caller: ReturnType<typeof createAuthenticatedCaller>) => caller.getBestProjectResourceDetail({
projectId: projectRankingWindow.projectId,
startDate: projectRankingWindow.startDate,
endDate: projectRankingWindow.endDate,
}),
},
])("requires planning read access for $name", async ({ invoke }) => {
const caller = createAuthenticatedCaller({});
await expect(invoke(caller)).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
});
it("allows explicit viewPlanning overrides to search capacity", async () => {
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCallerWithOverrides(db, {
granted: [PermissionKey.VIEW_PLANNING],
});
const result = await caller.searchCapacity({
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
});
expect(result).toEqual({
period: "2026-04-01 to 2026-04-02",
minHoursFilter: 4,
results: [],
totalFound: 0,
});
});
it("does not treat viewCosts as a substitute for viewPlanning on staffing reads", async () => {
const caller = createProtectedCallerWithOverrides({}, {
granted: [PermissionKey.VIEW_COSTS],
});
await expect(caller.searchCapacity({
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
});
it.each([
{
name: "getSuggestions",
invoke: (caller: ReturnType<typeof createProtectedCallerWithOverrides>) => caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: planningWindow.startDate,
endDate: planningWindow.endDate,
hoursPerDay: 8,
}),
},
{
name: "getProjectStaffingSuggestions",
invoke: (caller: ReturnType<typeof createProtectedCallerWithOverrides>) => caller.getProjectStaffingSuggestions({
projectId: projectRankingWindow.projectId,
}),
},
{
name: "findBestProjectResource",
invoke: (caller: ReturnType<typeof createProtectedCallerWithOverrides>) => caller.findBestProjectResource({
projectId: projectRankingWindow.projectId,
startDate: projectRankingWindow.startDate,
endDate: projectRankingWindow.endDate,
}),
},
{
name: "getBestProjectResourceDetail",
invoke: (caller: ReturnType<typeof createProtectedCallerWithOverrides>) => caller.getBestProjectResourceDetail({
projectId: projectRankingWindow.projectId,
startDate: projectRankingWindow.startDate,
endDate: projectRankingWindow.endDate,
}),
},
])("requires viewCosts for $name", async ({ invoke }) => {
const caller = createProtectedCallerWithOverrides({}, {
granted: [PermissionKey.VIEW_PLANNING],
});
await expect(invoke(caller)).rejects.toMatchObject({
code: "FORBIDDEN",
message: `Permission required: ${PermissionKey.VIEW_COSTS}`,
});
});
});
// ─── getSuggestions ────────────────────────────────────────────────────────── // ─── getSuggestions ──────────────────────────────────────────────────────────
describe("staffing.getSuggestions", () => { describe("staffing.getSuggestions", () => {
+4
View File
@@ -222,6 +222,7 @@ const COST_TOOLS = new Set([
"get_estimate_detail", "get_estimate_detail",
"get_estimate_version_snapshot", "get_estimate_version_snapshot",
"find_best_project_resource", "find_best_project_resource",
"get_staffing_suggestions",
]); ]);
/** Tools that follow planningReadProcedure access rules in the main API. */ /** Tools that follow planningReadProcedure access rules in the main API. */
@@ -229,6 +230,9 @@ const PLANNING_READ_TOOLS = new Set([
"list_allocations", "list_allocations",
"list_demands", "list_demands",
"check_resource_availability", "check_resource_availability",
"get_staffing_suggestions",
"find_capacity",
"find_best_project_resource",
]); ]);
/** Tools that follow controllerProcedure access rules in the main API. */ /** Tools that follow controllerProcedure access rules in the main API. */
+19 -11
View File
@@ -1,6 +1,6 @@
import { rankResources } from "@capakraken/staffing"; import { rankResources } from "@capakraken/staffing";
import { listAssignmentBookings } from "@capakraken/application"; import { listAssignmentBookings } from "@capakraken/application";
import type { WeekdayAvailability } from "@capakraken/shared"; import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { import {
@@ -11,7 +11,7 @@ import {
type ResourceDailyAvailabilityContext, type ResourceDailyAvailabilityContext,
} from "../lib/resource-capacity.js"; } from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js"; import { fmtEur } from "../lib/format-utils.js";
import { createTRPCRouter, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js";
const DAY_KEYS: (keyof WeekdayAvailability)[] = [ const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday", "sunday",
@@ -860,7 +860,7 @@ export const staffingRouter = createTRPCRouter({
/** /**
* Get ranked resource suggestions for a staffing requirement. * Get ranked resource suggestions for a staffing requirement.
*/ */
getSuggestions: protectedProcedure getSuggestions: planningReadProcedure
.input( .input(
z.object({ z.object({
requiredSkills: z.array(z.string()), requiredSkills: z.array(z.string()),
@@ -875,9 +875,12 @@ export const staffingRouter = createTRPCRouter({
minProficiency: z.number().min(1).max(5).optional(), minProficiency: z.number().min(1).max(5).optional(),
}), }),
) )
.query(async ({ ctx, input }) => queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, input)), .query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS);
return queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, input);
}),
getProjectStaffingSuggestions: protectedProcedure getProjectStaffingSuggestions: planningReadProcedure
.input( .input(
z.object({ z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
@@ -888,6 +891,7 @@ export const staffingRouter = createTRPCRouter({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS);
const project = await findUniqueOrThrow(ctx.db.project.findUnique({ const project = await findUniqueOrThrow(ctx.db.project.findUnique({
where: { id: input.projectId }, where: { id: input.projectId },
select: { select: {
@@ -937,7 +941,7 @@ export const staffingRouter = createTRPCRouter({
}; };
}), }),
searchCapacity: protectedProcedure searchCapacity: planningReadProcedure
.input( .input(
z.object({ z.object({
startDate: z.coerce.date(), startDate: z.coerce.date(),
@@ -1064,7 +1068,7 @@ export const staffingRouter = createTRPCRouter({
/** /**
* Analyze utilization for a specific resource over a date range. * Analyze utilization for a specific resource over a date range.
*/ */
analyzeUtilization: protectedProcedure analyzeUtilization: planningReadProcedure
.input( .input(
z.object({ z.object({
resourceId: z.string(), resourceId: z.string(),
@@ -1179,7 +1183,7 @@ export const staffingRouter = createTRPCRouter({
/** /**
* Find capacity windows for a resource. * Find capacity windows for a resource.
*/ */
findCapacity: protectedProcedure findCapacity: planningReadProcedure
.input( .input(
z.object({ z.object({
resourceId: z.string(), resourceId: z.string(),
@@ -1307,7 +1311,7 @@ export const staffingRouter = createTRPCRouter({
return windows; return windows;
}), }),
findBestProjectResource: protectedProcedure findBestProjectResource: planningReadProcedure
.input( .input(
z.object({ z.object({
projectId: z.string(), projectId: z.string(),
@@ -1319,9 +1323,12 @@ export const staffingRouter = createTRPCRouter({
roleName: z.string().optional(), roleName: z.string().optional(),
}), }),
) )
.query(async ({ ctx, input }) => queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, input)), .query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS);
return queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, input);
}),
getBestProjectResourceDetail: protectedProcedure getBestProjectResourceDetail: planningReadProcedure
.input( .input(
z.object({ z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
@@ -1335,6 +1342,7 @@ export const staffingRouter = createTRPCRouter({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS);
const project = await findUniqueOrThrow(ctx.db.project.findUnique({ const project = await findUniqueOrThrow(ctx.db.project.findUnique({
where: { id: input.projectId }, where: { id: input.projectId },
select: { select: {