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

@@ -2,6 +2,7 @@ import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIn
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { BudgetsService } from './budgets.service';
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
@@ -14,6 +15,7 @@ export class BudgetsController {
@Post(':year/import')
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
@RequireCapability('financials.budgets.edit')
importBudget(
@Param('year', ParseIntPipe) year: number,
@Body() lines: any[],
@@ -23,6 +25,7 @@ export class BudgetsController {
@Get(':year/template')
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
@RequireCapability('financials.budgets.view')
async getTemplate(
@Param('year', ParseIntPipe) year: number,
@Res() res: Response,
@@ -37,6 +40,7 @@ export class BudgetsController {
@Get(':year/vs-actual')
@ApiOperation({ summary: 'Budget vs actual comparison' })
@RequireCapability('financials.budgets.view')
budgetVsActual(
@Param('year', ParseIntPipe) year: number,
@Query('month') month?: string,
@@ -46,12 +50,14 @@ export class BudgetsController {
@Get(':year')
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
@RequireCapability('financials.budgets.view')
findByYear(@Param('year', ParseIntPipe) year: number) {
return this.budgetsService.findByYear(year);
}
@Put(':year')
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
@RequireCapability('financials.budgets.edit')
upsert(
@Param('year', ParseIntPipe) year: number,
@Body() budgets: UpsertBudgetDto[],