feat(auth): classify planning and resource read audiences
This commit is contained in:
@@ -666,7 +666,7 @@ describe("project router", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 });
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -735,6 +735,19 @@ describe("project router", () => {
|
||||
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||
});
|
||||
|
||||
it("blocks USER role from lightweight project search summaries", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 }),
|
||||
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||
});
|
||||
|
||||
it("returns lightweight project identifier reads from the canonical router", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
@@ -750,7 +763,7 @@ describe("project router", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getByIdentifier({ identifier: "GDM" });
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -854,5 +867,19 @@ describe("project router", () => {
|
||||
caller.getByIdentifierDetail({ identifier: "GDM" }),
|
||||
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||
});
|
||||
|
||||
it("blocks USER role from lightweight project identifier reads", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
caller.getByIdentifier({ identifier: "GDM" }),
|
||||
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -780,7 +780,7 @@ describe("resource router", () => {
|
||||
caller.listSummaries({ search: "Alice", limit: 10 }),
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "You need resource overview access to search resource summaries",
|
||||
message: "Resource overview access required",
|
||||
});
|
||||
expect(db.resource.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -980,7 +980,7 @@ describe("resource router", () => {
|
||||
caller.listStaff({ limit: 10 }),
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "You need resource overview access to list staff resource data",
|
||||
message: "Resource overview access required",
|
||||
});
|
||||
expect(db.resource.findMany).not.toHaveBeenCalled();
|
||||
expect(db.resource.count).not.toHaveBeenCalled();
|
||||
@@ -1301,7 +1301,7 @@ describe("resource router", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const caller = createProtectedCallerWithOverrides(db, { granted: [PermissionKey.VIEW_ALL_RESOURCES] });
|
||||
const result = await caller.resolveResponsiblePersonName({ name: "Peter" });
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -1310,6 +1310,25 @@ describe("resource router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects responsible-person resolution for regular users without resource overview access", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
caller.resolveResponsiblePersonName({ name: "Peter" }),
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Resource overview access required",
|
||||
});
|
||||
expect(db.resource.findFirst).not.toHaveBeenCalled();
|
||||
expect(db.resource.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies country filters on the staff list including explicit no-country toggle", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { appRouter, type AppRouter } from "./router/index.js";
|
||||
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } from "./trpc.js";
|
||||
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, resourceOverviewProcedure, planningReadProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } from "./trpc.js";
|
||||
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
|
||||
export { logger } from "./lib/logger.js";
|
||||
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } fr
|
||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
||||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, planningReadProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
|
||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||
@@ -411,7 +411,7 @@ async function readProjectByIdentifierDetailSnapshot(
|
||||
}
|
||||
|
||||
export const projectRouter = createTRPCRouter({
|
||||
resolveByIdentifier: protectedProcedure
|
||||
resolveByIdentifier: planningReadProcedure
|
||||
.input(z.object({ identifier: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const select = {
|
||||
@@ -454,7 +454,7 @@ export const projectRouter = createTRPCRouter({
|
||||
return project;
|
||||
}),
|
||||
|
||||
searchSummaries: protectedProcedure
|
||||
searchSummaries: planningReadProcedure
|
||||
.input(z.object({
|
||||
search: z.string().optional(),
|
||||
status: z.nativeEnum(ProjectStatus).optional(),
|
||||
@@ -577,7 +577,7 @@ export const projectRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getByIdentifier: protectedProcedure
|
||||
getByIdentifier: planningReadProcedure
|
||||
.input(z.object({ identifier: z.string() }))
|
||||
.query(async ({ ctx, input }) => resolveProjectIdentifierSnapshot(ctx, input.identifier)),
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Write a 2–3 sentence professional bio. Be specific, use skill names. No fluff.
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission, resourceOverviewProcedure } from "../trpc.js";
|
||||
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
|
||||
@@ -831,7 +831,7 @@ export const resourceRouter = createTRPCRouter({
|
||||
return resource;
|
||||
}),
|
||||
|
||||
resolveResponsiblePersonName: protectedProcedure
|
||||
resolveResponsiblePersonName: resourceOverviewProcedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const exact = await ctx.db.resource.findFirst({
|
||||
@@ -1176,7 +1176,7 @@ export const resourceRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
listSummaries: protectedProcedure
|
||||
listSummaries: resourceOverviewProcedure
|
||||
.input(z.object({
|
||||
search: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
@@ -1199,7 +1199,7 @@ export const resourceRouter = createTRPCRouter({
|
||||
return resources.map(mapResourceSummary);
|
||||
}),
|
||||
|
||||
listSummariesDetail: protectedProcedure
|
||||
listSummariesDetail: resourceOverviewProcedure
|
||||
.input(z.object({
|
||||
search: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
@@ -1433,17 +1433,9 @@ export const resourceRouter = createTRPCRouter({
|
||||
return { resources, total, page, limit, nextCursor };
|
||||
}),
|
||||
|
||||
listStaff: protectedProcedure
|
||||
listStaff: resourceOverviewProcedure
|
||||
.input(ResourceListQuerySchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!canReadAllResources(ctx)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You need resource overview access to list staff resource data",
|
||||
});
|
||||
}
|
||||
return listStaffResources(ctx, input);
|
||||
}),
|
||||
.query(async ({ ctx, input }) => listStaffResources(ctx, input)),
|
||||
|
||||
/** Lightweight resource card for hover tooltips on the timeline. */
|
||||
getHoverCard: protectedProcedure
|
||||
|
||||
@@ -124,6 +124,61 @@ export const protectedProcedure = t.procedure.use(withLogging).use(({ ctx, next
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Resource overview procedure — requires broad people-directory visibility.
|
||||
* Accepts explicit permission grants, not just elevated roles.
|
||||
*/
|
||||
export const resourceOverviewProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
const user = ctx.dbUser;
|
||||
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
const permissions = resolvePermissions(
|
||||
user.systemRole as SystemRole,
|
||||
user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
|
||||
ctx.roleDefaults ?? undefined,
|
||||
);
|
||||
|
||||
if (
|
||||
!permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|
||||
&& !permissions.has(PermissionKey.MANAGE_RESOURCES)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Resource overview access required",
|
||||
});
|
||||
}
|
||||
|
||||
return next({ ctx: { ...ctx, user, permissions } });
|
||||
});
|
||||
|
||||
/**
|
||||
* Planning read procedure — allows broad planning/project read access without opening it to all users.
|
||||
* This is an interim audience gate until dedicated project-read permissions exist.
|
||||
*/
|
||||
export const planningReadProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
const user = ctx.dbUser;
|
||||
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
const permissions = resolvePermissions(
|
||||
user.systemRole as SystemRole,
|
||||
user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
|
||||
ctx.roleDefaults ?? undefined,
|
||||
);
|
||||
|
||||
if (
|
||||
!permissions.has(PermissionKey.VIEW_COSTS)
|
||||
&& !permissions.has(PermissionKey.MANAGE_PROJECTS)
|
||||
&& !permissions.has(PermissionKey.MANAGE_ALLOCATIONS)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Planning read access required",
|
||||
});
|
||||
}
|
||||
|
||||
return next({ ctx: { ...ctx, user, permissions } });
|
||||
});
|
||||
|
||||
/**
|
||||
* Manager procedure — requires MANAGER or ADMIN role.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user