diff --git a/apps/web/src/components/admin/SystemRolesClient.tsx b/apps/web/src/components/admin/SystemRolesClient.tsx index d97568c..5bd4385 100644 --- a/apps/web/src/components/admin/SystemRolesClient.tsx +++ b/apps/web/src/components/admin/SystemRolesClient.tsx @@ -8,6 +8,7 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; const ALL_PERMISSION_KEYS = Object.values(PermissionKey); const PERMISSION_LABELS: Record = { + viewPlanning: "View Planning", viewCosts: "View Costs", useAssistantAdvancedTools: "Assistant Advanced Tools", exportData: "Export Data", @@ -24,6 +25,7 @@ const PERMISSION_LABELS: Record = { }; const PERMISSION_DESCRIPTIONS: Record = { + viewPlanning: "Read project and allocation planning views without mutation access", viewCosts: "Access to cost data, budget views, and financial reports", useAssistantAdvancedTools: "Unlocks advanced AI assistant workflows for complex cross-entity analyses", exportData: "Export data to Excel, CSV, or PDF formats", diff --git a/apps/web/src/components/admin/UsersClient.tsx b/apps/web/src/components/admin/UsersClient.tsx index d0a7f84..8ae5f43 100644 --- a/apps/web/src/components/admin/UsersClient.tsx +++ b/apps/web/src/components/admin/UsersClient.tsx @@ -14,6 +14,7 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js"; const ALL_PERMISSION_KEYS = Object.values(PermissionKey); const PERMISSION_LABELS: Record = { + viewPlanning: "View Planning", viewCosts: "View Costs", useAssistantAdvancedTools: "Assistant Advanced Tools", exportData: "Export Data", diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 0f68c53..e6ed282 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -8,7 +8,7 @@ - `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` +- `planning-read`: users with `viewPlanning` - `controller-finance`: controller, manager, or admin through `controllerProcedure` - `manager-write`: manager or admin through `managerProcedure` - `admin-only`: admin through `adminProcedure` @@ -49,6 +49,6 @@ ## Immediate Follow-Ups -- introduce a dedicated project-read permission instead of the current interim `planning-read` composite +- monitor whether `viewPlanning` should later split into narrower project-read vs allocation-read audiences - 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 diff --git a/packages/api/src/__tests__/allocation-router.test.ts b/packages/api/src/__tests__/allocation-router.test.ts index 1417482..b541b4d 100644 --- a/packages/api/src/__tests__/allocation-router.test.ts +++ b/packages/api/src/__tests__/allocation-router.test.ts @@ -1,4 +1,4 @@ -import { AllocationStatus, SystemRole } from "@capakraken/shared"; +import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { allocationRouter } from "../router/allocation.js"; import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js"; @@ -92,6 +92,24 @@ function createProtectedCaller(db: Record) { }); } +function createProtectedCallerWithOverrides( + db: Record, + overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null, +) { + 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: overrides, + }, + }); +} + describe("allocation router authorization", () => { const planningWindow = { resourceId: "resource_1", @@ -140,6 +158,54 @@ describe("allocation router authorization", () => { }); }); + it("allows explicit viewPlanning overrides to read assignment lists", 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 = createProtectedCallerWithOverrides(db, { + granted: [PermissionKey.VIEW_PLANNING], + }); + const result = await caller.listAssignments({}); + + expect(result).toEqual([assignment]); + }); + + it("does not treat viewCosts as a substitute for viewPlanning on planning reads", async () => { + const caller = createProtectedCallerWithOverrides({}, { + granted: [PermissionKey.VIEW_COSTS], + }); + + await expect(caller.listAssignments({})).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + }); + it.each([ { name: "list", invoke: (caller: ReturnType) => caller.list({}) }, { name: "listView", invoke: (caller: ReturnType) => caller.listView({}) }, diff --git a/packages/api/src/__tests__/project-router.test.ts b/packages/api/src/__tests__/project-router.test.ts index 96ba9e2..fefffff 100644 --- a/packages/api/src/__tests__/project-router.test.ts +++ b/packages/api/src/__tests__/project-router.test.ts @@ -1,4 +1,4 @@ -import { OrderType, AllocationType, ProjectStatus, SystemRole } from "@capakraken/shared"; +import { OrderType, AllocationType, PermissionKey, ProjectStatus, SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { invalidateDashboardCache } from "../lib/cache.js"; import { logger } from "../lib/logger.js"; @@ -110,6 +110,24 @@ function createProtectedCaller(db: Record) { }); } +function createProtectedCallerWithOverrides( + db: Record, + overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null, +) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_1", + systemRole: SystemRole.USER, + permissionOverrides: overrides, + }, + }); +} + const sampleProject = { id: "project_1", shortCode: "PRJ-001", @@ -748,6 +766,61 @@ describe("project router", () => { ).rejects.toThrow(expect.objectContaining({ code: "FORBIDDEN" })); }); + it("allows explicit viewPlanning overrides to access lightweight project search summaries", async () => { + const db = { + project: { + findMany: vi.fn().mockResolvedValue([ + { + id: "project_1", + shortCode: "GDM", + name: "Gelddruckmaschine", + status: ProjectStatus.ACTIVE, + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-03-31T00:00:00.000Z"), + client: { name: "Acme Mobility" }, + }, + ]), + }, + }; + + const caller = createProtectedCallerWithOverrides(db, { + granted: [PermissionKey.VIEW_PLANNING], + }); + const result = await caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 }); + + expect(result).toEqual([ + { + id: "project_1", + code: "GDM", + name: "Gelddruckmaschine", + status: "ACTIVE", + start: "2026-01-01", + end: "2026-03-31", + client: "Acme Mobility", + }, + ]); + }); + + it("does not treat viewCosts as a substitute for viewPlanning on lightweight project search summaries", async () => { + const db = { + project: { + findMany: vi.fn(), + }, + }; + + const caller = createProtectedCallerWithOverrides(db, { + granted: [PermissionKey.VIEW_COSTS], + }); + await expect( + caller.searchSummaries({ search: "Gelddruckmaschine", limit: 10 }), + ).rejects.toThrow( + expect.objectContaining({ + code: "FORBIDDEN", + message: "Planning read access required", + }), + ); + }); + it("returns lightweight project identifier reads from the canonical router", async () => { const db = { project: { diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 24473e6..12f53af 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -152,8 +152,7 @@ export const resourceOverviewProcedure = protectedProcedure.use(({ ctx, next }) }); /** - * 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. + * Planning read procedure — requires the explicit broad planning read audience. */ export const planningReadProcedure = protectedProcedure.use(({ ctx, next }) => { const user = ctx.dbUser; @@ -165,11 +164,7 @@ export const planningReadProcedure = protectedProcedure.use(({ ctx, next }) => { ctx.roleDefaults ?? undefined, ); - if ( - !permissions.has(PermissionKey.VIEW_COSTS) - && !permissions.has(PermissionKey.MANAGE_PROJECTS) - && !permissions.has(PermissionKey.MANAGE_ALLOCATIONS) - ) { + if (!permissions.has(PermissionKey.VIEW_PLANNING)) { throw new TRPCError({ code: "FORBIDDEN", message: "Planning read access required", diff --git a/packages/db/prisma/migrations/20260330_system_role_config_view_planning.sql b/packages/db/prisma/migrations/20260330_system_role_config_view_planning.sql new file mode 100644 index 0000000..017b02c --- /dev/null +++ b/packages/db/prisma/migrations/20260330_system_role_config_view_planning.sql @@ -0,0 +1,63 @@ +INSERT INTO "system_role_configs" ( + "role", + "label", + "description", + "defaultPermissions", + "color", + "sortOrder" +) +VALUES + ( + 'ADMIN', + 'Admin', + 'Full platform administration and security management.', + '["viewPlanning","viewCosts","useAssistantAdvancedTools","exportData","importData","approveVacations","manageBlueprints","viewAllResources","manageResources","manageProjects","manageAllocations","manageRoles","manageUsers","viewScores"]'::jsonb, + 'purple', + 1 + ), + ( + 'MANAGER', + 'Manager', + 'Operational delivery management across resources, projects, and staffing.', + '["viewPlanning","viewCosts","exportData","importData","approveVacations","viewAllResources","manageResources","manageProjects","manageAllocations","manageRoles","viewScores"]'::jsonb, + 'blue', + 2 + ), + ( + 'CONTROLLER', + 'Controller', + 'Read-heavy planning, resource, and financial oversight.', + '["viewPlanning","viewCosts","exportData","viewAllResources"]'::jsonb, + 'amber', + 3 + ), + ( + 'USER', + 'User', + 'Standard authenticated access with self-service capabilities only.', + '[]'::jsonb, + 'gray', + 4 + ), + ( + 'VIEWER', + 'Viewer', + 'Restricted read-only access for limited observation scenarios.', + '[]'::jsonb, + 'gray', + 5 + ) +ON CONFLICT ("role") DO NOTHING; + +UPDATE "system_role_configs" +SET + "defaultPermissions" = CASE + WHEN jsonb_typeof("defaultPermissions") = 'array' + AND NOT ("defaultPermissions" @> '["viewPlanning"]'::jsonb) + THEN "defaultPermissions" || '["viewPlanning"]'::jsonb + WHEN "defaultPermissions" IS NULL + THEN '["viewPlanning"]'::jsonb + ELSE "defaultPermissions" + END, + "updatedAt" = CURRENT_TIMESTAMP +WHERE "role" IN ('ADMIN', 'MANAGER', 'CONTROLLER'); diff --git a/packages/db/src/reset-dispo-import.ts b/packages/db/src/reset-dispo-import.ts index a5bbdee..e8ff7ab 100644 --- a/packages/db/src/reset-dispo-import.ts +++ b/packages/db/src/reset-dispo-import.ts @@ -6,6 +6,7 @@ import { SystemRole } from "@capakraken/shared"; import { PrismaClient } from "@prisma/client"; import { assertDestructiveDbAllowed } from "./destructive-db-guard.js"; import { loadWorkspaceEnv, resolveWorkspacePath } from "./load-workspace-env.js"; +import { buildSystemRoleConfigSeedData } from "./system-role-config-defaults.js"; loadWorkspaceEnv(); @@ -139,6 +140,20 @@ async function bootstrapPlatform(adminEmail: string, adminPassword: string, admi }, }); + for (const config of buildSystemRoleConfigSeedData()) { + await prisma.systemRoleConfig.upsert({ + where: { role: config.role }, + update: { + label: config.label, + description: config.description, + defaultPermissions: config.defaultPermissions, + color: config.color, + sortOrder: config.sortOrder, + }, + create: config, + }); + } + return admin; } diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 5fd0f7d..7aac81d 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -15,11 +15,28 @@ import { hash } from "@node-rs/argon2"; import { getHolidayDemoProfileForIndex } from "./holiday-demo-profiles.js"; import { loadWorkspaceEnv } from "./load-workspace-env.js"; import { assertSafeSeedTarget } from "./safe-destructive-env.js"; +import { buildSystemRoleConfigSeedData } from "./system-role-config-defaults.js"; loadWorkspaceEnv(); const prisma = new PrismaClient(); +async function seedSystemRoleConfigs() { + for (const config of buildSystemRoleConfigSeedData()) { + await prisma.systemRoleConfig.upsert({ + where: { role: config.role }, + update: { + label: config.label, + description: config.description, + defaultPermissions: config.defaultPermissions, + color: config.color, + sortOrder: config.sortOrder, + }, + create: config, + }); + } +} + // ─── Skill helpers ───────────────────────────────────────────────────────────── interface SkillEntry { @@ -338,6 +355,7 @@ async function main() { }); console.warn(`Users: admin=${admin.id}, manager=${manager.id}, viewer=${viewer.id}`); + await seedSystemRoleConfigs(); // ── 2b. Create Dispo v2 entities ────────────────────────────────────────── diff --git a/packages/db/src/system-role-config-defaults.ts b/packages/db/src/system-role-config-defaults.ts new file mode 100644 index 0000000..55c0301 --- /dev/null +++ b/packages/db/src/system-role-config-defaults.ts @@ -0,0 +1,46 @@ +import { ROLE_DEFAULT_PERMISSIONS, SystemRole } from "@capakraken/shared"; + +export const SYSTEM_ROLE_CONFIG_DEFAULTS = [ + { + role: SystemRole.ADMIN, + label: "Admin", + description: "Full platform administration and security management.", + color: "purple", + sortOrder: 1, + }, + { + role: SystemRole.MANAGER, + label: "Manager", + description: "Operational delivery management across resources, projects, and staffing.", + color: "blue", + sortOrder: 2, + }, + { + role: SystemRole.CONTROLLER, + label: "Controller", + description: "Read-heavy planning, resource, and financial oversight.", + color: "amber", + sortOrder: 3, + }, + { + role: SystemRole.USER, + label: "User", + description: "Standard authenticated access with self-service capabilities only.", + color: "gray", + sortOrder: 4, + }, + { + role: SystemRole.VIEWER, + label: "Viewer", + description: "Restricted read-only access for limited observation scenarios.", + color: "gray", + sortOrder: 5, + }, +] as const; + +export function buildSystemRoleConfigSeedData() { + return SYSTEM_ROLE_CONFIG_DEFAULTS.map((config) => ({ + ...config, + defaultPermissions: ROLE_DEFAULT_PERMISSIONS[config.role], + })); +} diff --git a/packages/shared/src/types/permissions.ts b/packages/shared/src/types/permissions.ts index 00737dc..2def5fb 100644 --- a/packages/shared/src/types/permissions.ts +++ b/packages/shared/src/types/permissions.ts @@ -1,6 +1,7 @@ import { SystemRole } from "./enums.js"; export const PermissionKey = { + VIEW_PLANNING: "viewPlanning", VIEW_COSTS: "viewCosts", USE_ASSISTANT_ADVANCED_TOOLS: "useAssistantAdvancedTools", EXPORT_DATA: "exportData", @@ -28,6 +29,7 @@ export interface PermissionOverrides { export const ROLE_DEFAULT_PERMISSIONS: Record = { ADMIN: Object.values(PermissionKey), MANAGER: [ + PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS, PermissionKey.EXPORT_DATA, PermissionKey.IMPORT_DATA, @@ -40,6 +42,7 @@ export const ROLE_DEFAULT_PERMISSIONS: Record = { PermissionKey.VIEW_SCORES, ], CONTROLLER: [ + PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS, PermissionKey.EXPORT_DATA, PermissionKey.VIEW_ALL_RESOURCES,