feat(auth): tighten allocation read audiences

This commit is contained in:
2026-03-30 09:03:44 +02:00
parent db45829eca
commit a50ca09333
3 changed files with 130 additions and 13 deletions
+2 -2
View File
@@ -40,7 +40,7 @@
### `packages/api/src/router/allocation.ts` ### `packages/api/src/router/allocation.ts`
- broad planning and staffing reads should move from generic `protectedProcedure` to explicit `planning-read` or narrower follow-up audiences - `list`, `listView`, `listDemands`, `listAssignments`, `getAssignmentById`, `resolveAssignment`, `getDemandRequirementById`, `checkResourceAvailability`, `getResourceAvailabilityView`, `getResourceAvailabilitySummary`: `planning-read`
- mutations already sit behind `manager-write` - mutations already sit behind `manager-write`
### `packages/api/src/router/dashboard.ts` ### `packages/api/src/router/dashboard.ts`
@@ -49,6 +49,6 @@
## Immediate Follow-Ups ## Immediate Follow-Ups
- reclassify `allocation` read endpoints away from generic `protectedProcedure`
- introduce a dedicated project-read permission instead of the current interim `planning-read` composite - introduce a dedicated project-read permission instead of the current interim `planning-read` composite
- split `allocation` further into narrower future audiences where resource-capacity and staffing-demand reads diverge
- add authorization tests for every route listed above so the matrix is CI-enforced, not just documented - add authorization tests for every route listed above so the matrix is CI-enforced, not just documented
@@ -62,6 +62,123 @@ function createManagerCaller(db: Record<string, unknown>) {
}); });
} }
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_controller",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
describe("allocation router authorization", () => {
const planningWindow = {
resourceId: "resource_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
hoursPerDay: 8,
};
it("allows controllers to read assignment lists through the planning audience", async () => {
const assignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-02T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.ACTIVE,
metadata: {},
resource: null,
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: null,
demandRequirement: null,
};
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([assignment]),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createControllerCaller(db);
const result = await caller.listAssignments({});
expect(result).toEqual([assignment]);
expect(db.assignment.findMany).toHaveBeenCalledWith({
where: {},
include: expect.any(Object),
orderBy: { startDate: "asc" },
});
});
it.each([
{ name: "list", invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.list({}) },
{ name: "listView", invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.listView({}) },
{ name: "listDemands", invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.listDemands({}) },
{ name: "listAssignments", invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.listAssignments({}) },
{
name: "getAssignmentById",
invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.getAssignmentById({ id: "assignment_1" }),
},
{
name: "resolveAssignment",
invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.resolveAssignment({ assignmentId: "assignment_1" }),
},
{
name: "getDemandRequirementById",
invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.getDemandRequirementById({ id: "demand_1" }),
},
{
name: "checkResourceAvailability",
invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.checkResourceAvailability(planningWindow),
},
{
name: "getResourceAvailabilityView",
invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.getResourceAvailabilityView(planningWindow),
},
{
name: "getResourceAvailabilitySummary",
invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.getResourceAvailabilitySummary(planningWindow),
},
])("requires planning read access for $name", async ({ invoke }) => {
const caller = createProtectedCaller({});
await expect(invoke(caller)).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
});
});
function createDemandWorkflowDb(overrides: Record<string, unknown> = {}) { function createDemandWorkflowDb(overrides: Record<string, unknown> = {}) {
const db = { const db = {
project: { project: {
+11 -11
View File
@@ -43,7 +43,7 @@ import {
countEffectiveWorkingDays, countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts, loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js"; } from "../lib/resource-capacity.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { createTRPCRouter, managerProcedure, planningReadProcedure, requirePermission } from "../trpc.js";
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js"; import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
const DEMAND_INCLUDE = { const DEMAND_INCLUDE = {
@@ -658,7 +658,7 @@ function buildResourceAvailabilitySummary(
} }
export const allocationRouter = createTRPCRouter({ export const allocationRouter = createTRPCRouter({
list: protectedProcedure list: planningReadProcedure
.input( .input(
z.object({ z.object({
projectId: z.string().optional(), projectId: z.string().optional(),
@@ -671,7 +671,7 @@ export const allocationRouter = createTRPCRouter({
return readModel.allocations; return readModel.allocations;
}), }),
listView: protectedProcedure listView: planningReadProcedure
.input( .input(
z.object({ z.object({
projectId: z.string().optional(), projectId: z.string().optional(),
@@ -746,7 +746,7 @@ export const allocationRouter = createTRPCRouter({
return allocation; return allocation;
}), }),
listDemands: protectedProcedure listDemands: planningReadProcedure
.input( .input(
z.object({ z.object({
projectId: z.string().optional(), projectId: z.string().optional(),
@@ -774,7 +774,7 @@ export const allocationRouter = createTRPCRouter({
})); }));
}), }),
listAssignments: protectedProcedure listAssignments: planningReadProcedure
.input( .input(
z.object({ z.object({
projectId: z.string().optional(), projectId: z.string().optional(),
@@ -801,7 +801,7 @@ export const allocationRouter = createTRPCRouter({
); );
}), }),
getAssignmentById: protectedProcedure getAssignmentById: planningReadProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const assignment = await findUniqueOrThrow( const assignment = await findUniqueOrThrow(
@@ -821,7 +821,7 @@ export const allocationRouter = createTRPCRouter({
}; };
}), }),
resolveAssignment: protectedProcedure resolveAssignment: planningReadProcedure
.input(z.object({ .input(z.object({
assignmentId: z.string().optional(), assignmentId: z.string().optional(),
resourceId: z.string().optional(), resourceId: z.string().optional(),
@@ -833,7 +833,7 @@ export const allocationRouter = createTRPCRouter({
})) }))
.query(async ({ ctx, input }) => resolveAssignmentBySelection(ctx.db, input)), .query(async ({ ctx, input }) => resolveAssignmentBySelection(ctx.db, input)),
getDemandRequirementById: protectedProcedure getDemandRequirementById: planningReadProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => getDemandRequirementByIdOrThrow(ctx.db, input.id)), .query(async ({ ctx, input }) => getDemandRequirementByIdOrThrow(ctx.db, input.id)),
@@ -841,7 +841,7 @@ export const allocationRouter = createTRPCRouter({
* Check a resource's availability for a date range. * Check a resource's availability for a date range.
* Returns working days, existing allocations, conflict days, and available capacity. * Returns working days, existing allocations, conflict days, and available capacity.
*/ */
checkResourceAvailability: protectedProcedure checkResourceAvailability: planningReadProcedure
.input(z.object({ .input(z.object({
resourceId: z.string(), resourceId: z.string(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
@@ -853,7 +853,7 @@ export const allocationRouter = createTRPCRouter({
return availability; return availability;
}), }),
getResourceAvailabilityView: protectedProcedure getResourceAvailabilityView: planningReadProcedure
.input(z.object({ .input(z.object({
resourceId: z.string(), resourceId: z.string(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
@@ -862,7 +862,7 @@ export const allocationRouter = createTRPCRouter({
})) }))
.query(async ({ ctx, input }) => buildResourceAvailabilityView(ctx.db, input)), .query(async ({ ctx, input }) => buildResourceAvailabilityView(ctx.db, input)),
getResourceAvailabilitySummary: protectedProcedure getResourceAvailabilitySummary: planningReadProcedure
.input(z.object({ .input(z.object({
resourceId: z.string(), resourceId: z.string(),
startDate: z.coerce.date(), startDate: z.coerce.date(),