feat(auth): introduce explicit planning read permission

This commit is contained in:
2026-03-30 09:15:07 +02:00
parent a50ca09333
commit 93c4374973
11 changed files with 293 additions and 11 deletions
@@ -8,6 +8,7 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = {
viewPlanning: "View Planning",
viewCosts: "View Costs",
useAssistantAdvancedTools: "Assistant Advanced Tools",
exportData: "Export Data",
@@ -24,6 +25,7 @@ const PERMISSION_LABELS: Record<string, string> = {
};
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
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",
@@ -14,6 +14,7 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js";
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = {
viewPlanning: "View Planning",
viewCosts: "View Costs",
useAssistantAdvancedTools: "Assistant Advanced Tools",
exportData: "Export Data",
+2 -2
View File
@@ -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
@@ -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<string, unknown>) {
});
}
function createProtectedCallerWithOverrides(
db: Record<string, unknown>,
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<typeof createProtectedCaller>) => caller.list({}) },
{ name: "listView", invoke: (caller: ReturnType<typeof createProtectedCaller>) => caller.listView({}) },
@@ -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<string, unknown>) {
});
}
function createProtectedCallerWithOverrides(
db: Record<string, unknown>,
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: {
+2 -7
View File
@@ -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",
@@ -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');
+15
View File
@@ -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;
}
+18
View File
@@ -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 ──────────────────────────────────────────
@@ -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],
}));
}
+3
View File
@@ -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<SystemRole, PermissionKey[]> = {
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<SystemRole, PermissionKey[]> = {
PermissionKey.VIEW_SCORES,
],
CONTROLLER: [
PermissionKey.VIEW_PLANNING,
PermissionKey.VIEW_COSTS,
PermissionKey.EXPORT_DATA,
PermissionKey.VIEW_ALL_RESOURCES,