feat(auth): classify planning and resource read audiences
This commit is contained in:
+37
-114
@@ -1,131 +1,54 @@
|
|||||||
# Route Access Matrix
|
# Route Access Matrix
|
||||||
|
|
||||||
**Date:** 2026-03-29
|
**Date:** 2026-03-30
|
||||||
**Purpose:** Explicit interim audience model for sensitive API read routes while the broader least-privilege refactor is still in progress.
|
**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.
|
- `self-service`: authenticated users can only read or mutate data that belongs to their linked resource or account
|
||||||
- `controllerProcedure`: planning, financial, staffing, or portfolio-wide analytics that should only be visible to `CONTROLLER`, `MANAGER`, or `ADMIN`.
|
- `authenticated-safe-lookup`: authenticated users can access a deliberately narrow, identity-safe lookup surface
|
||||||
- Ownership checks: self-service routes stay user-accessible, but only for the caller's own linked resource unless elevated staff access applies.
|
- `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 |
|
### `packages/api/src/router/project.ts`
|
||||||
| --- | --- | --- |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
### 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 |
|
- `getMyEntriesView`, `getMyHolidayOverlays`: `self-service`
|
||||||
| --- | --- | --- |
|
- timeline-wide planning reads and shift previews: `controller-finance`
|
||||||
| `previewRequest` | self-service | personal validation before request creation |
|
- allocation updates, quick-assign, project shifts: `manager-write`
|
||||||
| `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 |
|
|
||||||
|
|
||||||
### 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 |
|
### `packages/api/src/router/dashboard.ts`
|
||||||
| --- | --- | --- |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
### 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.
|
- reclassify `allocation` read endpoints away from generic `protectedProcedure`
|
||||||
- `resource.listStaff` is the explicit staff-only route for estimate, staffing, and scenario-planning screens that need cost-sensitive resource data.
|
- 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
|
||||||
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:<id>` 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`.
|
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ describe("project router", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createControllerCaller(db);
|
||||||
const result = await caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 });
|
const result = await caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 });
|
||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
@@ -735,6 +735,19 @@ describe("project router", () => {
|
|||||||
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
).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 () => {
|
it("returns lightweight project identifier reads from the canonical router", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
project: {
|
project: {
|
||||||
@@ -750,7 +763,7 @@ describe("project router", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createControllerCaller(db);
|
||||||
const result = await caller.getByIdentifier({ identifier: "GDM" });
|
const result = await caller.getByIdentifier({ identifier: "GDM" });
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -854,5 +867,19 @@ describe("project router", () => {
|
|||||||
caller.getByIdentifierDetail({ identifier: "GDM" }),
|
caller.getByIdentifierDetail({ identifier: "GDM" }),
|
||||||
).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" }));
|
).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 }),
|
caller.listSummaries({ search: "Alice", limit: 10 }),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "You need resource overview access to search resource summaries",
|
message: "Resource overview access required",
|
||||||
});
|
});
|
||||||
expect(db.resource.findMany).not.toHaveBeenCalled();
|
expect(db.resource.findMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -980,7 +980,7 @@ describe("resource router", () => {
|
|||||||
caller.listStaff({ limit: 10 }),
|
caller.listStaff({ limit: 10 }),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
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.findMany).not.toHaveBeenCalled();
|
||||||
expect(db.resource.count).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" });
|
const result = await caller.resolveResponsiblePersonName({ name: "Peter" });
|
||||||
|
|
||||||
expect(result).toEqual({
|
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 () => {
|
it("applies country filters on the staff list including explicit no-country toggle", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { appRouter, type AppRouter } from "./router/index.js";
|
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 { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
|
||||||
export { logger } from "./lib/logger.js";
|
export { logger } from "./lib/logger.js";
|
||||||
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.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 { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.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 { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||||
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
|
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
|
||||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||||
@@ -411,7 +411,7 @@ async function readProjectByIdentifierDetailSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const projectRouter = createTRPCRouter({
|
export const projectRouter = createTRPCRouter({
|
||||||
resolveByIdentifier: protectedProcedure
|
resolveByIdentifier: planningReadProcedure
|
||||||
.input(z.object({ identifier: z.string() }))
|
.input(z.object({ identifier: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const select = {
|
const select = {
|
||||||
@@ -454,7 +454,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
return project;
|
return project;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
searchSummaries: protectedProcedure
|
searchSummaries: planningReadProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
status: z.nativeEnum(ProjectStatus).optional(),
|
status: z.nativeEnum(ProjectStatus).optional(),
|
||||||
@@ -577,7 +577,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getByIdentifier: protectedProcedure
|
getByIdentifier: planningReadProcedure
|
||||||
.input(z.object({ identifier: z.string() }))
|
.input(z.object({ identifier: z.string() }))
|
||||||
.query(async ({ ctx, input }) => resolveProjectIdentifierSnapshot(ctx, input.identifier)),
|
.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 { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
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 { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
import type { TRPCContext } from "../trpc.js";
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
@@ -831,7 +831,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
return resource;
|
return resource;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
resolveResponsiblePersonName: protectedProcedure
|
resolveResponsiblePersonName: resourceOverviewProcedure
|
||||||
.input(z.object({ name: z.string() }))
|
.input(z.object({ name: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const exact = await ctx.db.resource.findFirst({
|
const exact = await ctx.db.resource.findFirst({
|
||||||
@@ -1176,7 +1176,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listSummaries: protectedProcedure
|
listSummaries: resourceOverviewProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
country: z.string().optional(),
|
country: z.string().optional(),
|
||||||
@@ -1199,7 +1199,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
return resources.map(mapResourceSummary);
|
return resources.map(mapResourceSummary);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listSummariesDetail: protectedProcedure
|
listSummariesDetail: resourceOverviewProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
country: z.string().optional(),
|
country: z.string().optional(),
|
||||||
@@ -1433,17 +1433,9 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
return { resources, total, page, limit, nextCursor };
|
return { resources, total, page, limit, nextCursor };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listStaff: protectedProcedure
|
listStaff: resourceOverviewProcedure
|
||||||
.input(ResourceListQuerySchema)
|
.input(ResourceListQuerySchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => listStaffResources(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);
|
|
||||||
}),
|
|
||||||
|
|
||||||
/** Lightweight resource card for hover tooltips on the timeline. */
|
/** Lightweight resource card for hover tooltips on the timeline. */
|
||||||
getHoverCard: protectedProcedure
|
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.
|
* Manager procedure — requires MANAGER or ADMIN role.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user