feat(auth): introduce explicit planning read permission
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user