feat(auth): tighten allocation read audiences
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user