diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 38fa359..0c59b3d 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -1,131 +1,54 @@ # Route Access Matrix -**Date:** 2026-03-29 -**Purpose:** Explicit interim audience model for sensitive API read routes while the broader least-privilege refactor is still in progress. +**Date:** 2026-03-30 +**Purpose:** Make high-sensitivity API audiences explicit and reduce ambiguous `protectedProcedure` usage on broad read routes. -## Decision Rules +## Audience Classes -- `protectedProcedure`: only for clearly personal or low-sensitivity reads. -- `controllerProcedure`: planning, financial, staffing, or portfolio-wide analytics that should only be visible to `CONTROLLER`, `MANAGER`, or `ADMIN`. -- Ownership checks: self-service routes stay user-accessible, but only for the caller's own linked resource unless elevated staff access applies. +- `self-service`: authenticated users can only read or mutate data that belongs to their linked resource or account +- `authenticated-safe-lookup`: authenticated users can access a deliberately narrow, identity-safe lookup surface +- `resource-overview`: users with `viewAllResources` or `manageResources` +- `planning-read`: users with at least one of `viewCosts`, `manageProjects`, or `manageAllocations` +- `controller-finance`: controller, manager, or admin through `controllerProcedure` +- `manager-write`: manager or admin through `managerProcedure` +- `admin-only`: admin through `adminProcedure` -## Applied In This Pass +## Current Classification -### Dashboard +### `packages/api/src/router/resource.ts` -All routes in [dashboard.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/dashboard.ts) are treated as portfolio analytics and now require `controllerProcedure`. +- `getMyResource`: `self-service` +- `getById`, `getByEid`, `getHoverCard`, `getByIdentifier`, `getByIdentifierDetail`, `resolveByIdentifier`, `getChargeabilitySummary`: `self-service` unless the caller also has `resource-overview` +- `directory`: `authenticated-safe-lookup` +- `listSummaries`, `listSummariesDetail`, `listStaff`, `resolveResponsiblePersonName`: `resource-overview` +- `getSkillsAnalytics`, `searchBySkills`, `listWithUtilization`, `getChargeabilityStats`, `getSkillMarketplace`: `controller-finance` +- create, update, deactivate, batch update, imports for other users: `manager-write` or `admin-only` -| Route | Classification | Reason | -| --- | --- | --- | -| `getOverview` | `controllerProcedure` | exposes global resource/project counts and budget context | -| `getPeakTimes` | `controllerProcedure` | exposes portfolio-wide demand/utilization peaks | -| `getTopValueResources` | `controllerProcedure` | exposes ranked value/cost-related resource data | -| `getDemand` | `controllerProcedure` | exposes staffing demand by project/person/chapter | -| `getDetail` | `controllerProcedure` | aggregates the above into assistant-facing strategic detail | -| `getChargeabilityOverview` | `controllerProcedure` | already correctly scoped | -| `getBudgetForecast` | `controllerProcedure` | exposes budget burn and exhaustion projections | -| `getSkillGaps` | `controllerProcedure` | exposes org-wide capability shortfalls | -| `getSkillGapSummary` | `controllerProcedure` | summary variant of strategic skill analytics | -| `getProjectHealth` | `controllerProcedure` | exposes portfolio-level delivery risk indicators | +### `packages/api/src/router/project.ts` -### Vacation +- `resolveByIdentifier`, `searchSummaries`, `getByIdentifier`: `planning-read` +- `searchSummariesDetail`, `list`, `getById`, `getByIdentifierDetail`, `getShoringRatio`, `listWithCosts`: `controller-finance` +- create, update, status changes, cover mutations: `manager-write` +- delete and batch delete: `admin-only` +- `isImageGenConfigured`, `isDalleConfigured`: authenticated low-risk configuration checks -Routes in [vacation.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/vacation.ts) now distinguish between self-service and staff oversight. +### `packages/api/src/router/timeline.ts` -| Route | Classification | Reason | -| --- | --- | --- | -| `previewRequest` | self-service | personal validation before request creation | -| `create` | self-service with ownership check | users may request only for their own resource | -| `cancel` | self-service with ownership check | users may cancel only their own requests | -| `list` | self-service scoped to own resource, or full staff view for manager/admin | broad vacation visibility is sensitive absence data | -| `getById` | self-service scoped to own vacation, or full staff view for manager/admin | direct object lookup should not bypass ownership | -| `getForResource` | self-service scoped to own resource, or full staff view for manager/admin | calculator support should not expose foreign absence data | -| `getTeamOverlap` | self-service scoped to own resource, or full staff view for manager/admin | overlap warnings are valid, but only for the caller's team context | -| `getPendingApprovals` | manager/admin | approval queue is supervisory data | +- `getMyEntriesView`, `getMyHolidayOverlays`: `self-service` +- timeline-wide planning reads and shift previews: `controller-finance` +- allocation updates, quick-assign, project shifts: `manager-write` -### Resource +### `packages/api/src/router/allocation.ts` -Routes in [resource.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/resource.ts) remain partially deferred, but the clearest sensitive reads are now explicitly scoped. +- broad planning and staffing reads should move from generic `protectedProcedure` to explicit `planning-read` or narrower follow-up audiences +- mutations already sit behind `manager-write` -| Route | Classification | Reason | -| --- | --- | --- | -| `directory` | authenticated safe directory | dedicated low-sensitivity directory for generic pickers, filters, calendars, and lookups; returns only `id`, `eid`, `displayName`, `chapter`, and `isActive`, limits search to name/EID, and preserves anonymization behavior | -| `getMyResource` | self-service | explicit route for the caller's linked resource | -| `getChargeabilitySummary` | self-service scoped to own resource, or staff with `VIEW_ALL_RESOURCES` | exposes detailed capacity, holiday, assignment, and target data for an individual resource | -| `getValueScores` | explicit permission gate `VIEW_SCORES` | ranked score output should not depend on ad hoc session-role strings | -| `getById` | self-service scoped to own resource, or staff with `VIEW_ALL_RESOURCES` | full resource detail page includes person-level operational and cost context | -| `getByEid` | self-service scoped to own resource, or staff with `VIEW_ALL_RESOURCES` | direct identifier lookup should not bypass ownership | -| `getHoverCard` | self-service scoped to own resource, or staff with `VIEW_ALL_RESOURCES` | hover card exposes rates, role, skills, and staffing targets | -| `getByIdentifier` | exact self lookup for regular users; broad lookup for staff with `VIEW_ALL_RESOURCES` | lightweight identifier read returns only identity-safe fields (`id`, `eid`, `displayName`, `chapter`, `isActive`) | -| `getByIdentifierDetail` | exact self lookup for regular users; broad lookup for staff with `VIEW_ALL_RESOURCES` | explicit detail route for assistant and UI flows that truly need rates, targets, org placement, and skill/count context | -| `resolveByIdentifier` | exact self lookup for regular users; broad lookup for staff with `VIEW_ALL_RESOURCES` | minimal identity resolver used by tool chains to convert free-form names/EIDs into canonical IDs without leaking cost or location detail | -| `listSummaries` | staff with `VIEW_ALL_RESOURCES` | staff-only org search that returns non-financial summary cards for discovery and candidate selection | -| `listSummariesDetail` | staff with `VIEW_ALL_RESOURCES` | explicit richer search variant for assistant/staff workflows that need FTE, LCR, and chargeability context | -| `listStaff` | staff with `VIEW_ALL_RESOURCES` | explicit staff-only list for cost-aware, role-aware, and estimate/planning workflows; supports email search, rates, roles, and dynamic field filters | +### `packages/api/src/router/dashboard.ts` -### Resource Directory Split +- all current routes are `controller-finance` -This pass introduces an explicit audience split in [resource.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/resource.ts): +## Immediate Follow-Ups -- `resource.directory` is the default route for generic UI selectors and org-directory style lookups. -- `resource.listStaff` is the explicit staff-only route for estimate, staffing, and scenario-planning screens that need cost-sensitive resource data. - -The following web consumers now use `resource.directory`: - -- generic resource comboboxes and assignment pickers -- vacation filters and team calendar selectors -- timeline quick filters, toolbar lookup, and project panel add-member search -- project responsible-person picker -- computation graph resource selector -- batch skill import resource matching - -### Project - -Routes in [project.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/project.ts) now distinguish between lightweight project discovery and planning/commercial detail. - -| Route | Classification | Reason | -| --- | --- | --- | -| `resolveByIdentifier` | authenticated safe resolver | minimal project identity lookup for names/codes/IDs without commercial detail | -| `searchSummaries` | authenticated safe summary search | lightweight project discovery returns only code, name, status, dates, and client | -| `searchSummariesDetail` | `controllerProcedure` | exposes budget, win probability, and staffing/estimate counts | -| `getByIdentifier` | authenticated safe identifier read | exact/fuzzy lookup returns only identity-safe project fields | -| `getByIdentifierDetail` | `controllerProcedure` | exposes commercial and staffing detail, including budget, responsible person, category, and top allocations | -| `list` | `controllerProcedure` | broad project listing can expose commercial/custom-field planning context | -| `getById` | `controllerProcedure` | full project read model includes allocations, demands, and assignments | -| `getShoringRatio` | `controllerProcedure` | derived staffing geography analytics should not be generally user-visible | - -### Timeline - -Routes in [timeline.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/timeline.ts) now split personal self-service reads from broad planning views. - -| Route | Classification | Reason | -| --- | --- | --- | -| `getEntries` | `controllerProcedure` | returns broad staffing allocations across projects/resources for a time window | -| `getEntriesView` | `controllerProcedure` | exposes the full timeline read model, including demands and assignments | -| `getHolidayOverlays` | `controllerProcedure` | org-wide absence overlays reveal staffing availability context | -| `getMyEntriesView` | self-service scoped to own linked resource | personal timeline view for `USER`/`VIEWER`; ignores foreign resource scoping and never broadens beyond the caller's linked resource | -| `getMyHolidayOverlays` | self-service scoped to own linked resource | personal holiday overlays for the caller's own timeline window without org-wide absence visibility | -| `getEntriesDetail` | `controllerProcedure` | assistant-facing planning detail aggregates allocations, demands, assignments, and holiday summaries | -| `getHolidayOverlayDetail` | `controllerProcedure` | detailed overlay summaries are planning-sensitive absence context | -| `getProjectContext` | `controllerProcedure` | project-side planning context includes all allocations and cross-resource context | -| `getProjectContextDetail` | `controllerProcedure` | detailed project timeline context exposes conflict and overlap analysis | -| `previewShift` | `controllerProcedure` | shift preview computes operational and budget impacts before mutation | -| `getShiftPreviewDetail` | `controllerProcedure` | detail variant includes project metadata plus cost/conflict preview | -| `getBudgetStatus` | `controllerProcedure` | budget burn/remaining exposure is commercial data | - -### Timeline SSE - -The live-update path in [event-bus.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/sse/event-bus.ts) and [route.ts](/home/hartmut/Documents/Copilot/capakraken/apps/web/src/app/api/sse/timeline/route.ts) now follows the same audience model as the timeline reads: - -- planning staff subscribe through role/permission audiences -- linked users additionally subscribe to their own `resource:` audience -- allocation, vacation, and project-shift events fan out to both staff planning audiences and the affected resource audiences -- self-service timeline clients invalidate both personal entries and personal holiday overlays on allocation and vacation events - -## Review Standard - -- Any new sensitive read route must document one of: - - personal self-service ownership - - explicit role gate - - explicit permission gate -- Any route returning portfolio-wide financial, staffing, scheduling, or HR absence data should not default to plain `protectedProcedure`. +- reclassify `allocation` read endpoints away from generic `protectedProcedure` +- introduce a dedicated project-read permission instead of the current interim `planning-read` composite +- add authorization tests for every route listed above so the matrix is CI-enforced, not just documented diff --git a/packages/api/src/__tests__/project-router.test.ts b/packages/api/src/__tests__/project-router.test.ts index 61af0b3..96ba9e2 100644 --- a/packages/api/src/__tests__/project-router.test.ts +++ b/packages/api/src/__tests__/project-router.test.ts @@ -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" })); + }); }); }); diff --git a/packages/api/src/__tests__/resource-router.test.ts b/packages/api/src/__tests__/resource-router.test.ts index 3490f33..85e618b 100644 --- a/packages/api/src/__tests__/resource-router.test.ts +++ b/packages/api/src/__tests__/resource-router.test.ts @@ -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: { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 8f85e4b..98c5c35 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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"; diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index c0d75e4..ea6f1ec 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -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)), diff --git a/packages/api/src/router/resource.ts b/packages/api/src/router/resource.ts index 31fdeb3..bf64662 100644 --- a/packages/api/src/router/resource.ts +++ b/packages/api/src/router/resource.ts @@ -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 diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index f638b6b..24473e6 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -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. */