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", () => {
|
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", () => {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user