feat(staffing): enforce planning and cost audiences
This commit is contained in:
@@ -208,8 +208,12 @@ describe("assistant router tool gating", () => {
|
||||
});
|
||||
|
||||
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([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
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_demands");
|
||||
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_demands");
|
||||
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", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { PermissionKey, SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { staffingRouter } from "../router/staffing.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
@@ -33,6 +33,23 @@ const createCaller = createCallerFactory(staffingRouter);
|
||||
// ── Caller factories ─────────────────────────────────────────────────────────
|
||||
|
||||
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({
|
||||
session: {
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("staffing.getSuggestions", () => {
|
||||
|
||||
@@ -222,6 +222,7 @@ const COST_TOOLS = new Set([
|
||||
"get_estimate_detail",
|
||||
"get_estimate_version_snapshot",
|
||||
"find_best_project_resource",
|
||||
"get_staffing_suggestions",
|
||||
]);
|
||||
|
||||
/** Tools that follow planningReadProcedure access rules in the main API. */
|
||||
@@ -229,6 +230,9 @@ const PLANNING_READ_TOOLS = new Set([
|
||||
"list_allocations",
|
||||
"list_demands",
|
||||
"check_resource_availability",
|
||||
"get_staffing_suggestions",
|
||||
"find_capacity",
|
||||
"find_best_project_resource",
|
||||
]);
|
||||
|
||||
/** Tools that follow controllerProcedure access rules in the main API. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { rankResources } from "@capakraken/staffing";
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type ResourceDailyAvailabilityContext,
|
||||
} from "../lib/resource-capacity.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)[] = [
|
||||
"sunday",
|
||||
@@ -860,7 +860,7 @@ export const staffingRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get ranked resource suggestions for a staffing requirement.
|
||||
*/
|
||||
getSuggestions: protectedProcedure
|
||||
getSuggestions: planningReadProcedure
|
||||
.input(
|
||||
z.object({
|
||||
requiredSkills: z.array(z.string()),
|
||||
@@ -875,9 +875,12 @@ export const staffingRouter = createTRPCRouter({
|
||||
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(
|
||||
z.object({
|
||||
projectId: z.string().min(1),
|
||||
@@ -888,6 +891,7 @@ export const staffingRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
||||
const project = await findUniqueOrThrow(ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
@@ -937,7 +941,7 @@ export const staffingRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
searchCapacity: protectedProcedure
|
||||
searchCapacity: planningReadProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.coerce.date(),
|
||||
@@ -1064,7 +1068,7 @@ export const staffingRouter = createTRPCRouter({
|
||||
/**
|
||||
* Analyze utilization for a specific resource over a date range.
|
||||
*/
|
||||
analyzeUtilization: protectedProcedure
|
||||
analyzeUtilization: planningReadProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
@@ -1179,7 +1183,7 @@ export const staffingRouter = createTRPCRouter({
|
||||
/**
|
||||
* Find capacity windows for a resource.
|
||||
*/
|
||||
findCapacity: protectedProcedure
|
||||
findCapacity: planningReadProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
@@ -1307,7 +1311,7 @@ export const staffingRouter = createTRPCRouter({
|
||||
return windows;
|
||||
}),
|
||||
|
||||
findBestProjectResource: protectedProcedure
|
||||
findBestProjectResource: planningReadProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -1319,9 +1323,12 @@ export const staffingRouter = createTRPCRouter({
|
||||
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(
|
||||
z.object({
|
||||
projectId: z.string().min(1),
|
||||
@@ -1335,6 +1342,7 @@ export const staffingRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.VIEW_COSTS);
|
||||
const project = await findUniqueOrThrow(ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
|
||||
Reference in New Issue
Block a user