feat(auth): classify planning and resource read audiences

This commit is contained in:
2026-03-30 08:51:07 +02:00
parent f6daf21983
commit db45829eca
7 changed files with 154 additions and 138 deletions
@@ -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 -1
View File
@@ -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";
+4 -4
View File
@@ -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)),
+6 -14
View File
@@ -51,7 +51,7 @@ Write a 23 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
+55
View File
@@ -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.
*/