feat: add flexible capability-based RBAC with per-tenant customization

Introduces a capability layer on top of existing roles that controls
feature visibility and access. Capabilities follow an area.feature.action
taxonomy (~35 capabilities) with sensible defaults per role. Tenant admins
can customize via grant/revoke overrides stored in org settings JSONB.

Key changes:
- Add vice_president role to DB schema
- Backend: capability constants, resolution logic, CapabilityGuard (global),
  @RequireCapability decorator on all 16 tenant controllers
- Frontend: permission hooks (useCanEdit, useHasCapability), CapabilityGate
  component, sidebar filtering by capability, all 17 pages migrated from
  useIsReadOnly to capability-based checks
- New admin UI: /settings/permissions matrix page for per-tenant role
  customization with grant/revoke delta model
- GET /organizations/my-capabilities endpoint for capability refresh
- Validation of permissionOverrides in settings updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 15:28:14 -04:00
parent 5fec296569
commit 43b10869f0
55 changed files with 1351 additions and 86 deletions

View File

@@ -3,6 +3,7 @@ import { Response } from 'express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { BoardPlanningService } from './board-planning.service';
import { BoardPlanningProjectionService } from './board-planning-projection.service';
import { BudgetPlanningService } from './budget-planning.service';
@@ -22,27 +23,32 @@ export class BoardPlanningController {
@Get('scenarios')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listScenarios(@Query('type') type?: string) {
return this.service.listScenarios(type);
}
@Get('scenarios/:id')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getScenario(@Param('id') id: string) {
return this.service.getScenario(id);
}
@Post('scenarios')
@RequireCapability('planning.scenarios.edit')
createScenario(@Body() dto: any, @Req() req: any) {
return this.service.createScenario(dto, req.user.sub);
}
@Put('scenarios/:id')
@RequireCapability('planning.scenarios.edit')
updateScenario(@Param('id') id: string, @Body() dto: any) {
return this.service.updateScenario(id, dto);
}
@Delete('scenarios/:id')
@RequireCapability('planning.scenarios.edit')
deleteScenario(@Param('id') id: string) {
return this.service.deleteScenario(id);
}
@@ -51,26 +57,31 @@ export class BoardPlanningController {
@Get('scenarios/:scenarioId/investments')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listInvestments(@Param('scenarioId') scenarioId: string) {
return this.service.listInvestments(scenarioId);
}
@Post('scenarios/:scenarioId/investments')
@RequireCapability('planning.scenarios.edit')
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestment(scenarioId, dto);
}
@Post('scenarios/:scenarioId/investments/from-recommendation')
@RequireCapability('planning.scenarios.edit')
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
}
@Put('investments/:id')
@RequireCapability('planning.scenarios.edit')
updateInvestment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateInvestment(id, dto);
}
@Delete('investments/:id')
@RequireCapability('planning.scenarios.edit')
removeInvestment(@Param('id') id: string) {
return this.service.removeInvestment(id);
}
@@ -79,21 +90,25 @@ export class BoardPlanningController {
@Get('scenarios/:scenarioId/assessments')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listAssessments(@Param('scenarioId') scenarioId: string) {
return this.service.listAssessments(scenarioId);
}
@Post('scenarios/:scenarioId/assessments')
@RequireCapability('planning.scenarios.edit')
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addAssessment(scenarioId, dto);
}
@Put('assessments/:id')
@RequireCapability('planning.scenarios.edit')
updateAssessment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateAssessment(id, dto);
}
@Delete('assessments/:id')
@RequireCapability('planning.scenarios.edit')
removeAssessment(@Param('id') id: string) {
return this.service.removeAssessment(id);
}
@@ -102,11 +117,13 @@ export class BoardPlanningController {
@Get('scenarios/:id/projection')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getProjection(@Param('id') id: string) {
return this.projection.getProjection(id);
}
@Post('scenarios/:id/projection/refresh')
@RequireCapability('planning.scenarios.edit')
refreshProjection(@Param('id') id: string) {
return this.projection.computeProjection(id);
}
@@ -115,6 +132,7 @@ export class BoardPlanningController {
@Get('compare')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
compareScenarios(@Query('ids') ids: string) {
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
return this.projection.compareScenarios(scenarioIds);
@@ -123,6 +141,7 @@ export class BoardPlanningController {
// ── Execute Investment ──
@Post('investments/:id/execute')
@RequireCapability('planning.scenarios.edit')
executeInvestment(
@Param('id') id: string,
@Body() dto: { executionDate: string },
@@ -135,43 +154,51 @@ export class BoardPlanningController {
@Get('budget-plans')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listBudgetPlans() {
return this.budgetPlanning.listPlans();
}
@Get('budget-plans/available-years')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getAvailableYears() {
return this.budgetPlanning.getAvailableYears();
}
@Get('budget-plans/:year')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.getPlan(parseInt(year, 10));
}
@Post('budget-plans')
@RequireCapability('planning.scenarios.edit')
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
}
@Put('budget-plans/:year/lines')
@RequireCapability('planning.scenarios.edit')
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
}
@Put('budget-plans/:year/inflation')
@RequireCapability('planning.scenarios.edit')
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
}
@Put('budget-plans/:year/status')
@RequireCapability('planning.scenarios.edit')
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
}
@Post('budget-plans/:year/import')
@RequireCapability('planning.scenarios.edit')
importBudgetPlanLines(
@Param('year') year: string,
@Body() lines: any[],
@@ -181,6 +208,7 @@ export class BoardPlanningController {
}
@Get('budget-plans/:year/template')
@RequireCapability('planning.scenarios.view')
async getBudgetPlanTemplate(
@Param('year') year: string,
@Res() res: Response,
@@ -194,6 +222,7 @@ export class BoardPlanningController {
}
@Delete('budget-plans/:year')
@RequireCapability('planning.scenarios.edit')
deleteBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.deletePlan(parseInt(year, 10));
}