diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8cca28c..3e8c30b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AppController } from './app.controller'; import { DatabaseModule } from './database/database.module'; import { TenantMiddleware } from './database/tenant.middleware'; import { WriteAccessGuard } from './common/guards/write-access.guard'; +import { CapabilityGuard } from './common/guards/capability.guard'; import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor'; import { AuthModule } from './modules/auth/auth.module'; import { OrganizationsModule } from './modules/organizations/organizations.module'; @@ -100,6 +101,10 @@ import { ScheduleModule } from '@nestjs/schedule'; provide: APP_GUARD, useClass: WriteAccessGuard, }, + { + provide: APP_GUARD, + useClass: CapabilityGuard, + }, { provide: APP_INTERCEPTOR, useClass: NoCacheInterceptor, diff --git a/backend/src/common/decorators/capability.decorator.ts b/backend/src/common/decorators/capability.decorator.ts new file mode 100644 index 0000000..92895cf --- /dev/null +++ b/backend/src/common/decorators/capability.decorator.ts @@ -0,0 +1,14 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CAPABILITIES_KEY = 'required_capabilities'; + +/** + * Decorator to require specific capabilities on an endpoint. + * User must have ALL listed capabilities to access the endpoint. + * + * Usage: + * @RequireCapability('financials.accounts.edit') + * @RequireCapability('financials.accounts.view', 'financials.accounts.edit') + */ +export const RequireCapability = (...capabilities: string[]) => + SetMetadata(CAPABILITIES_KEY, capabilities); diff --git a/backend/src/common/guards/capability.guard.ts b/backend/src/common/guards/capability.guard.ts new file mode 100644 index 0000000..a318ff6 --- /dev/null +++ b/backend/src/common/guards/capability.guard.ts @@ -0,0 +1,83 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { DataSource } from 'typeorm'; +import { CAPABILITIES_KEY } from '../decorators/capability.decorator'; +import { resolveCapabilities } from '../permissions'; + +@Injectable() +export class CapabilityGuard implements CanActivate { + // Cache org settings (including permissionOverrides) per orgId + private settingsCache = new Map; cachedAt: number }>(); + private static readonly CACHE_TTL = 60_000; // 60 seconds + + constructor( + private reflector: Reflector, + private dataSource: DataSource, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredCapabilities = this.reflector.getAllAndOverride(CAPABILITIES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + // No capabilities required — pass through (backward compatible) + if (!requiredCapabilities || requiredCapabilities.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + // No authenticated user — let other guards handle auth + if (!user) return true; + + // Superadmins bypass all capability checks + if (user.isSuperadmin) return true; + + const role = user.role; + const orgId = user.orgId; + + if (!role || !orgId) return true; + + // Get org settings (with caching) + const settings = await this.getOrgSettings(orgId); + const userCapabilities = resolveCapabilities(role, settings?.permissionOverrides); + + // User must have ALL required capabilities + const hasAll = requiredCapabilities.every((cap) => userCapabilities.has(cap)); + if (!hasAll) { + throw new ForbiddenException( + 'You do not have the required permissions for this action.', + ); + } + + return true; + } + + private async getOrgSettings(orgId: string): Promise | null> { + const cached = this.settingsCache.get(orgId); + if (cached && Date.now() - cached.cachedAt < CapabilityGuard.CACHE_TTL) { + return cached.settings; + } + try { + const result = await this.dataSource.query( + `SELECT settings FROM shared.organizations WHERE id = $1`, + [orgId], + ); + if (result.length > 0) { + const settings = result[0].settings || {}; + this.settingsCache.set(orgId, { settings, cachedAt: Date.now() }); + return settings; + } + } catch { + // Non-critical — fall through to use defaults only + } + return null; + } + + /** Clear cached settings for an org (call after settings update) */ + clearCache(orgId: string) { + this.settingsCache.delete(orgId); + } +} diff --git a/backend/src/common/permissions/capabilities.ts b/backend/src/common/permissions/capabilities.ts new file mode 100644 index 0000000..30e6b9e --- /dev/null +++ b/backend/src/common/permissions/capabilities.ts @@ -0,0 +1,65 @@ +/** + * Capability taxonomy for the HOA Financial Platform. + * + * Pattern: {area}.{feature}.{action} + * Actions: view, edit, approve, manage + * + * Add new capabilities here when new features are built. + * The default role matrix in ./default-role-capabilities.ts must also be updated. + */ +export const CAPABILITIES = { + // Dashboard + DASHBOARD_VIEW: 'dashboard.view', + + // Financials + FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view', + FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit', + FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view', + FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit', + FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view', + FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit', + FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view', + FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit', + FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve', + + // Assessments + ASSESSMENTS_UNITS_VIEW: 'assessments.units.view', + ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit', + ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view', + ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit', + + // Board Planning + PLANNING_BUDGETS_VIEW: 'planning.budgets.view', + PLANNING_BUDGETS_EDIT: 'planning.budgets.edit', + PLANNING_PROJECTS_VIEW: 'planning.projects.view', + PLANNING_PROJECTS_EDIT: 'planning.projects.edit', + PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view', + PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit', + PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve', + PLANNING_INVESTMENTS_VIEW: 'planning.investments.view', + PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit', + + // Board Reference + REFERENCE_VENDORS_VIEW: 'reference.vendors.view', + REFERENCE_VENDORS_EDIT: 'reference.vendors.edit', + + // Transactions + TRANSACTIONS_VIEW: 'transactions.view', + TRANSACTIONS_EDIT: 'transactions.edit', + TRANSACTIONS_APPROVE: 'transactions.approve', + + // Reports + REPORTS_VIEW: 'reports.view', + + // Settings & Administration + SETTINGS_ORG_VIEW: 'settings.org.view', + SETTINGS_ORG_EDIT: 'settings.org.edit', + SETTINGS_MEMBERS_VIEW: 'settings.members.view', + SETTINGS_MEMBERS_MANAGE: 'settings.members.manage', + SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage', +} as const; + +export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES]; + +/** Set of all valid capability strings, for validation */ +export const ALL_CAPABILITIES = new Set(Object.values(CAPABILITIES)); diff --git a/backend/src/common/permissions/default-role-capabilities.ts b/backend/src/common/permissions/default-role-capabilities.ts new file mode 100644 index 0000000..9a1cd24 --- /dev/null +++ b/backend/src/common/permissions/default-role-capabilities.ts @@ -0,0 +1,157 @@ +import { CAPABILITIES, Capability } from './capabilities'; + +const C = CAPABILITIES; + +/** + * Default capability sets per role. + * + * These represent sensible defaults for a typical HOA. Tenant admins can + * customize per-role capabilities via permission overrides in org settings. + * + * Roles not listed here (e.g. unknown future roles) get zero capabilities. + */ +export const DEFAULT_ROLE_CAPABILITIES: Record = { + president: [ + C.DASHBOARD_VIEW, + C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT, + C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT, + C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT, + C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE, + C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT, + C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT, + C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT, + C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT, + C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE, + C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT, + C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT, + C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE, + C.REPORTS_VIEW, + C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT, + C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE, + C.SETTINGS_PERMISSIONS_MANAGE, + ], + + admin: [ + C.DASHBOARD_VIEW, + C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT, + C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT, + C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT, + C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE, + C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT, + C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT, + C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT, + C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT, + C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE, + C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT, + C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT, + C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE, + C.REPORTS_VIEW, + C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT, + C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE, + C.SETTINGS_PERMISSIONS_MANAGE, + ], + + vice_president: [ + C.DASHBOARD_VIEW, + C.FINANCIALS_ACCOUNTS_VIEW, + C.FINANCIALS_CASHFLOW_VIEW, + C.FINANCIALS_ACTUALS_VIEW, + C.FINANCIALS_BUDGETS_VIEW, + C.ASSESSMENTS_UNITS_VIEW, + C.ASSESSMENTS_GROUPS_VIEW, + C.PLANNING_BUDGETS_VIEW, + C.PLANNING_PROJECTS_VIEW, + C.PLANNING_SCENARIOS_VIEW, + C.PLANNING_INVESTMENTS_VIEW, + C.REFERENCE_VENDORS_VIEW, + C.TRANSACTIONS_VIEW, + C.REPORTS_VIEW, + C.SETTINGS_ORG_VIEW, + C.SETTINGS_MEMBERS_VIEW, + ], + + treasurer: [ + C.DASHBOARD_VIEW, + C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT, + C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT, + C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT, + C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, + C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT, + C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT, + C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT, + C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT, + C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, + C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT, + C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT, + C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, + C.REPORTS_VIEW, + C.SETTINGS_MEMBERS_VIEW, + ], + + secretary: [ + C.DASHBOARD_VIEW, + C.FINANCIALS_ACCOUNTS_VIEW, + C.FINANCIALS_CASHFLOW_VIEW, + C.FINANCIALS_ACTUALS_VIEW, + C.FINANCIALS_BUDGETS_VIEW, + C.ASSESSMENTS_UNITS_VIEW, + C.ASSESSMENTS_GROUPS_VIEW, + C.PLANNING_BUDGETS_VIEW, + C.PLANNING_PROJECTS_VIEW, + C.PLANNING_SCENARIOS_VIEW, + C.PLANNING_INVESTMENTS_VIEW, + C.REFERENCE_VENDORS_VIEW, + C.REPORTS_VIEW, + ], + + member_at_large: [ + C.DASHBOARD_VIEW, + C.FINANCIALS_ACCOUNTS_VIEW, + C.FINANCIALS_CASHFLOW_VIEW, + C.FINANCIALS_ACTUALS_VIEW, + C.FINANCIALS_BUDGETS_VIEW, + C.ASSESSMENTS_UNITS_VIEW, + C.ASSESSMENTS_GROUPS_VIEW, + C.PLANNING_BUDGETS_VIEW, + C.PLANNING_PROJECTS_VIEW, + C.PLANNING_SCENARIOS_VIEW, + C.PLANNING_INVESTMENTS_VIEW, + C.REFERENCE_VENDORS_VIEW, + C.REPORTS_VIEW, + ], + + manager: [ + C.DASHBOARD_VIEW, + C.FINANCIALS_ACCOUNTS_VIEW, + C.FINANCIALS_CASHFLOW_VIEW, + C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT, + C.FINANCIALS_BUDGETS_VIEW, + C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT, + C.ASSESSMENTS_GROUPS_VIEW, + C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT, + C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, + C.REPORTS_VIEW, + ], + + homeowner: [ + C.DASHBOARD_VIEW, + C.REPORTS_VIEW, + ], + + viewer: [ + C.DASHBOARD_VIEW, + C.FINANCIALS_ACCOUNTS_VIEW, + C.FINANCIALS_CASHFLOW_VIEW, + C.FINANCIALS_ACTUALS_VIEW, + C.FINANCIALS_BUDGETS_VIEW, + C.ASSESSMENTS_UNITS_VIEW, + C.ASSESSMENTS_GROUPS_VIEW, + C.PLANNING_BUDGETS_VIEW, + C.PLANNING_PROJECTS_VIEW, + C.PLANNING_SCENARIOS_VIEW, + C.PLANNING_INVESTMENTS_VIEW, + C.REFERENCE_VENDORS_VIEW, + C.TRANSACTIONS_VIEW, + C.REPORTS_VIEW, + ], +}; diff --git a/backend/src/common/permissions/index.ts b/backend/src/common/permissions/index.ts new file mode 100644 index 0000000..40424ff --- /dev/null +++ b/backend/src/common/permissions/index.ts @@ -0,0 +1,5 @@ +export { CAPABILITIES, ALL_CAPABILITIES } from './capabilities'; +export type { Capability } from './capabilities'; +export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities'; +export { resolveCapabilities, resolveCapabilitiesArray } from './resolve-permissions'; +export type { PermissionOverrides } from './resolve-permissions'; diff --git a/backend/src/common/permissions/resolve-permissions.ts b/backend/src/common/permissions/resolve-permissions.ts new file mode 100644 index 0000000..7ec26b4 --- /dev/null +++ b/backend/src/common/permissions/resolve-permissions.ts @@ -0,0 +1,57 @@ +import { ALL_CAPABILITIES } from './capabilities'; +import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities'; + +export interface PermissionOverrides { + [role: string]: { + grant?: string[]; + revoke?: string[]; + }; +} + +/** + * Resolve effective capabilities for a role, applying tenant overrides. + * + * 1. Start with default capabilities for the role + * 2. Add any granted capabilities from overrides + * 3. Remove any revoked capabilities from overrides + * + * Unknown capabilities in grant/revoke are silently ignored (they may + * come from an older version of the overrides). + */ +export function resolveCapabilities( + role: string, + overrides?: PermissionOverrides | null, +): Set { + const defaults = DEFAULT_ROLE_CAPABILITIES[role] || []; + const result = new Set(defaults); + + if (overrides && overrides[role]) { + const roleOverride = overrides[role]; + + if (roleOverride.grant) { + for (const cap of roleOverride.grant) { + if (ALL_CAPABILITIES.has(cap)) { + result.add(cap); + } + } + } + + if (roleOverride.revoke) { + for (const cap of roleOverride.revoke) { + result.delete(cap); + } + } + } + + return result; +} + +/** + * Convenience: resolve to a sorted array (for API responses). + */ +export function resolveCapabilitiesArray( + role: string, + overrides?: PermissionOverrides | null, +): string[] { + return Array.from(resolveCapabilities(role, overrides)).sort(); +} diff --git a/backend/src/modules/accounts/accounts.controller.ts b/backend/src/modules/accounts/accounts.controller.ts index 2c07733..5acb942 100644 --- a/backend/src/modules/accounts/accounts.controller.ts +++ b/backend/src/modules/accounts/accounts.controller.ts @@ -3,6 +3,7 @@ import { } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { AccountsService } from './accounts.service'; import { CreateAccountDto } from './dto/create-account.dto'; import { UpdateAccountDto } from './dto/update-account.dto'; @@ -16,24 +17,28 @@ export class AccountsController { @Get() @ApiOperation({ summary: 'List all accounts' }) + @RequireCapability('financials.accounts.view') findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) { return this.accountsService.findAll(fundType, includeArchived === 'true'); } @Get('trial-balance') @ApiOperation({ summary: 'Get trial balance' }) + @RequireCapability('financials.accounts.view') getTrialBalance(@Query('asOfDate') asOfDate?: string) { return this.accountsService.getTrialBalance(asOfDate); } @Put(':id/set-primary') @ApiOperation({ summary: 'Set account as primary for its fund type' }) + @RequireCapability('financials.accounts.edit') setPrimary(@Param('id') id: string) { return this.accountsService.setPrimary(id); } @Post('bulk-opening-balances') @ApiOperation({ summary: 'Set opening balances for multiple accounts' }) + @RequireCapability('financials.accounts.edit') bulkSetOpeningBalances( @Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }, ) { @@ -42,6 +47,7 @@ export class AccountsController { @Post(':id/opening-balance') @ApiOperation({ summary: 'Set opening balance for an account at a specific date' }) + @RequireCapability('financials.accounts.edit') setOpeningBalance( @Param('id') id: string, @Body() dto: { targetBalance: number; asOfDate: string; memo?: string }, @@ -51,6 +57,7 @@ export class AccountsController { @Post(':id/adjust-balance') @ApiOperation({ summary: 'Adjust account balance to a target amount' }) + @RequireCapability('financials.accounts.edit') adjustBalance( @Param('id') id: string, @Body() dto: { targetBalance: number; asOfDate: string; memo?: string }, @@ -60,6 +67,7 @@ export class AccountsController { @Post('transfer') @ApiOperation({ summary: 'Transfer funds between asset accounts' }) + @RequireCapability('financials.accounts.edit') transferFunds( @Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string }, ) { @@ -68,18 +76,21 @@ export class AccountsController { @Get(':id') @ApiOperation({ summary: 'Get account by ID' }) + @RequireCapability('financials.accounts.view') findOne(@Param('id') id: string) { return this.accountsService.findOne(id); } @Post() @ApiOperation({ summary: 'Create a new account' }) + @RequireCapability('financials.accounts.edit') create(@Body() dto: CreateAccountDto) { return this.accountsService.create(dto); } @Put(':id') @ApiOperation({ summary: 'Update an account' }) + @RequireCapability('financials.accounts.edit') update(@Param('id') id: string, @Body() dto: UpdateAccountDto) { return this.accountsService.update(id, dto); } diff --git a/backend/src/modules/assessment-groups/assessment-groups.controller.ts b/backend/src/modules/assessment-groups/assessment-groups.controller.ts index 333a852..f767332 100644 --- a/backend/src/modules/assessment-groups/assessment-groups.controller.ts +++ b/backend/src/modules/assessment-groups/assessment-groups.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { AssessmentGroupsService } from './assessment-groups.service'; @ApiTags('assessment-groups') @@ -11,23 +12,30 @@ export class AssessmentGroupsController { constructor(private service: AssessmentGroupsService) {} @Get() + @RequireCapability('assessments.groups.view') findAll() { return this.service.findAll(); } @Get('summary') + @RequireCapability('assessments.groups.view') getSummary() { return this.service.getSummary(); } @Get('default') + @RequireCapability('assessments.groups.view') getDefault() { return this.service.getDefault(); } @Get(':id') + @RequireCapability('assessments.groups.view') findOne(@Param('id') id: string) { return this.service.findOne(id); } @Post() + @RequireCapability('assessments.groups.edit') create(@Body() dto: any) { return this.service.create(dto); } @Put(':id') + @RequireCapability('assessments.groups.edit') update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); } @Put(':id/set-default') + @RequireCapability('assessments.groups.edit') setDefault(@Param('id') id: string) { return this.service.setDefault(id); } } diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 31fdb98..31415aa 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -17,6 +17,7 @@ import { EmailService } from '../email/email.service'; import { RegisterDto } from './dto/register.dto'; import { User } from '../users/entities/user.entity'; import { RefreshTokenService } from './refresh-token.service'; +import { resolveCapabilitiesArray } from '../../common/permissions'; @Injectable() export class AuthService { @@ -162,6 +163,12 @@ export class AuthService { // Generate new refresh token for org switch const refreshToken = await this.refreshTokenService.createRefreshToken(user.id); + const orgSettings = membership.organization.settings || {}; + const capabilities = resolveCapabilitiesArray( + membership.role, + orgSettings.permissionOverrides, + ); + return { accessToken: this.jwtService.sign(payload), refreshToken, @@ -169,7 +176,8 @@ export class AuthService { id: membership.organization.id, name: membership.organization.name, role: membership.role, - settings: membership.organization.settings || {}, + settings: orgSettings, + capabilities, }, }; } @@ -468,12 +476,16 @@ export class AuthService { hasSeenIntro: user.hasSeenIntro || false, mfaEnabled: user.mfaEnabled || false, }, - organizations: orgs.map((uo) => ({ - id: uo.organizationId, - name: uo.organization?.name, - status: uo.organization?.status, - role: uo.role, - })), + organizations: orgs.map((uo) => { + const settings = uo.organization?.settings || {}; + return { + id: uo.organizationId, + name: uo.organization?.name, + status: uo.organization?.status, + role: uo.role, + capabilities: resolveCapabilitiesArray(uo.role, settings.permissionOverrides), + }; + }), }; } diff --git a/backend/src/modules/board-planning/board-planning.controller.ts b/backend/src/modules/board-planning/board-planning.controller.ts index 91fdca4..7692db9 100644 --- a/backend/src/modules/board-planning/board-planning.controller.ts +++ b/backend/src/modules/board-planning/board-planning.controller.ts @@ -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)); } diff --git a/backend/src/modules/budgets/budgets.controller.ts b/backend/src/modules/budgets/budgets.controller.ts index 5120860..1900172 100644 --- a/backend/src/modules/budgets/budgets.controller.ts +++ b/backend/src/modules/budgets/budgets.controller.ts @@ -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[], diff --git a/backend/src/modules/capital-projects/capital-projects.controller.ts b/backend/src/modules/capital-projects/capital-projects.controller.ts index 4f2b864..c4c54fc 100644 --- a/backend/src/modules/capital-projects/capital-projects.controller.ts +++ b/backend/src/modules/capital-projects/capital-projects.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { CapitalProjectsService } from './capital-projects.service'; @ApiTags('capital-projects') @@ -11,14 +12,18 @@ export class CapitalProjectsController { constructor(private service: CapitalProjectsService) {} @Get() + @RequireCapability('planning.projects.view') findAll() { return this.service.findAll(); } @Get(':id') + @RequireCapability('planning.projects.view') findOne(@Param('id') id: string) { return this.service.findOne(id); } @Post() + @RequireCapability('planning.projects.edit') create(@Body() dto: any) { return this.service.create(dto); } @Put(':id') + @RequireCapability('planning.projects.edit') update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); } } diff --git a/backend/src/modules/investment-planning/investment-planning.controller.ts b/backend/src/modules/investment-planning/investment-planning.controller.ts index 4d3c087..4b2222a 100644 --- a/backend/src/modules/investment-planning/investment-planning.controller.ts +++ b/backend/src/modules/investment-planning/investment-planning.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } 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 { InvestmentPlanningService } from './investment-planning.service'; @ApiTags('investment-planning') @@ -13,24 +14,28 @@ export class InvestmentPlanningController { @Get('snapshot') @ApiOperation({ summary: 'Get financial snapshot for investment planning' }) + @RequireCapability('planning.investments.view') getSnapshot() { return this.service.getFinancialSnapshot(); } @Get('cd-rates') @ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' }) + @RequireCapability('planning.investments.view') getCdRates() { return this.service.getCdRates(); } @Get('market-rates') @ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' }) + @RequireCapability('planning.investments.view') getMarketRates() { return this.service.getMarketRates(); } @Get('saved-recommendation') @ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' }) + @RequireCapability('planning.investments.view') getSavedRecommendation() { return this.service.getSavedRecommendation(); } @@ -38,6 +43,7 @@ export class InvestmentPlanningController { @Post('recommendations') @ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' }) @AllowViewer() + @RequireCapability('planning.investments.edit') triggerRecommendations(@Req() req: any) { return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId); } diff --git a/backend/src/modules/investments/investments.controller.ts b/backend/src/modules/investments/investments.controller.ts index ac00c3e..6265c24 100644 --- a/backend/src/modules/investments/investments.controller.ts +++ b/backend/src/modules/investments/investments.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { InvestmentsService } from './investments.service'; @ApiTags('investments') @@ -11,14 +12,18 @@ export class InvestmentsController { constructor(private service: InvestmentsService) {} @Get() + @RequireCapability('planning.investments.view') findAll() { return this.service.findAll(); } @Get(':id') + @RequireCapability('planning.investments.view') findOne(@Param('id') id: string) { return this.service.findOne(id); } @Post() + @RequireCapability('planning.investments.edit') create(@Body() dto: any) { return this.service.create(dto); } @Put(':id') + @RequireCapability('planning.investments.edit') update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); } } diff --git a/backend/src/modules/invoices/invoices.controller.ts b/backend/src/modules/invoices/invoices.controller.ts index b728330..9938deb 100644 --- a/backend/src/modules/invoices/invoices.controller.ts +++ b/backend/src/modules/invoices/invoices.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { InvoicesService } from './invoices.service'; @ApiTags('invoices') @@ -11,22 +12,27 @@ export class InvoicesController { constructor(private invoicesService: InvoicesService) {} @Get() + @RequireCapability('transactions.view') findAll() { return this.invoicesService.findAll(); } @Get(':id') + @RequireCapability('transactions.view') findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); } @Post('generate-preview') + @RequireCapability('transactions.edit') generatePreview(@Body() dto: { month: number; year: number }) { return this.invoicesService.generatePreview(dto); } @Post('generate-bulk') + @RequireCapability('transactions.edit') generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) { return this.invoicesService.generateBulk(dto, req.user.sub); } @Post('apply-late-fees') + @RequireCapability('transactions.edit') applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) { return this.invoicesService.applyLateFees(dto, req.user.sub); } diff --git a/backend/src/modules/journal-entries/journal-entries.controller.ts b/backend/src/modules/journal-entries/journal-entries.controller.ts index c007a6f..899dfb2 100644 --- a/backend/src/modules/journal-entries/journal-entries.controller.ts +++ b/backend/src/modules/journal-entries/journal-entries.controller.ts @@ -3,6 +3,7 @@ import { } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { JournalEntriesService } from './journal-entries.service'; import { CreateJournalEntryDto } from './dto/create-journal-entry.dto'; import { VoidJournalEntryDto } from './dto/void-journal-entry.dto'; @@ -16,6 +17,7 @@ export class JournalEntriesController { @Get() @ApiOperation({ summary: 'List journal entries' }) + @RequireCapability('transactions.view') findAll( @Query('from') from?: string, @Query('to') to?: string, @@ -27,24 +29,28 @@ export class JournalEntriesController { @Get(':id') @ApiOperation({ summary: 'Get journal entry by ID' }) + @RequireCapability('transactions.view') findOne(@Param('id') id: string) { return this.jeService.findOne(id); } @Post() @ApiOperation({ summary: 'Create a journal entry' }) + @RequireCapability('transactions.edit') create(@Body() dto: CreateJournalEntryDto, @Request() req: any) { return this.jeService.create(dto, req.user.sub); } @Post(':id/post') @ApiOperation({ summary: 'Post (finalize) a journal entry' }) + @RequireCapability('transactions.edit') post(@Param('id') id: string, @Request() req: any) { return this.jeService.post(id, req.user.sub); } @Post(':id/void') @ApiOperation({ summary: 'Void a journal entry' }) + @RequireCapability('transactions.edit') void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) { return this.jeService.void(id, req.user.sub, dto.reason); } diff --git a/backend/src/modules/monthly-actuals/monthly-actuals.controller.ts b/backend/src/modules/monthly-actuals/monthly-actuals.controller.ts index 78c124c..6bce4cf 100644 --- a/backend/src/modules/monthly-actuals/monthly-actuals.controller.ts +++ b/backend/src/modules/monthly-actuals/monthly-actuals.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { MonthlyActualsService } from './monthly-actuals.service'; @ApiTags('monthly-actuals') @@ -12,12 +13,14 @@ export class MonthlyActualsController { @Get(':year/:month') @ApiOperation({ summary: 'Get monthly actuals grid for a specific month' }) + @RequireCapability('financials.actuals.view') async getGrid(@Param('year') year: string, @Param('month') month: string) { return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month)); } @Post(':year/:month') @ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' }) + @RequireCapability('financials.actuals.edit') async save( @Param('year') year: string, @Param('month') month: string, diff --git a/backend/src/modules/organizations/organizations.controller.ts b/backend/src/modules/organizations/organizations.controller.ts index 5f94117..2a99942 100644 --- a/backend/src/modules/organizations/organizations.controller.ts +++ b/backend/src/modules/organizations/organizations.controller.ts @@ -3,6 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { OrganizationsService } from './organizations.service'; import { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; +import { resolveCapabilitiesArray, ALL_CAPABILITIES } from '../../common/permissions'; @ApiTags('organizations') @Controller('organizations') @@ -23,54 +25,87 @@ export class OrganizationsController { return this.orgService.findByUser(req.user.sub); } + @Get('my-capabilities') + @ApiOperation({ summary: 'Get resolved capabilities for current user in current org' }) + async getMyCapabilities(@Request() req: any) { + const org = await this.orgService.findById(req.user.orgId); + const settings = org?.settings || {}; + const capabilities = resolveCapabilitiesArray(req.user.role, settings.permissionOverrides); + return { role: req.user.role, capabilities }; + } + @Patch('settings') @ApiOperation({ summary: 'Update settings for the current organization' }) + @RequireCapability('settings.org.edit') async updateSettings(@Request() req: any, @Body() body: Record) { - this.requireTenantAdmin(req); + // Validate permissionOverrides if present + if (body.permissionOverrides) { + this.validatePermissionOverrides(body.permissionOverrides); + } return this.orgService.updateSettings(req.user.orgId, body); } // ── Org Member Management ── - private requireTenantAdmin(req: any) { - const role = req.user.role; - if (!['president', 'admin', 'treasurer'].includes(role) && !req.user.isSuperadmin) { - throw new ForbiddenException('Only organization administrators can manage members'); - } - } - @Get('members') @ApiOperation({ summary: 'List members of current organization' }) + @RequireCapability('settings.members.view') async getMembers(@Request() req: any) { - this.requireTenantAdmin(req); return this.orgService.getMembers(req.user.orgId); } @Post('members') @ApiOperation({ summary: 'Add a member to the current organization' }) + @RequireCapability('settings.members.manage') async addMember( @Request() req: any, @Body() body: { email: string; firstName: string; lastName: string; password: string; role: string }, ) { - this.requireTenantAdmin(req); return this.orgService.addMember(req.user.orgId, body); } @Put('members/:id/role') @ApiOperation({ summary: 'Update a member role' }) + @RequireCapability('settings.members.manage') async updateMemberRole( @Request() req: any, @Param('id') id: string, @Body() body: { role: string }, ) { - this.requireTenantAdmin(req); return this.orgService.updateMemberRole(req.user.orgId, id, body.role); } @Delete('members/:id') @ApiOperation({ summary: 'Remove a member from the organization' }) + @RequireCapability('settings.members.manage') async removeMember(@Request() req: any, @Param('id') id: string) { - this.requireTenantAdmin(req); return this.orgService.removeMember(req.user.orgId, id, req.user.sub); } + + private validatePermissionOverrides(overrides: any) { + if (typeof overrides !== 'object' || overrides === null) { + throw new ForbiddenException('permissionOverrides must be an object'); + } + const validRoles = ['president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer']; + for (const role of Object.keys(overrides)) { + if (!validRoles.includes(role)) { + throw new ForbiddenException(`Invalid role in permissionOverrides: ${role}`); + } + const entry = overrides[role]; + if (entry.grant) { + for (const cap of entry.grant) { + if (!ALL_CAPABILITIES.has(cap)) { + throw new ForbiddenException(`Unknown capability in grant: ${cap}`); + } + } + } + if (entry.revoke) { + for (const cap of entry.revoke) { + if (!ALL_CAPABILITIES.has(cap)) { + throw new ForbiddenException(`Unknown capability in revoke: ${cap}`); + } + } + } + } + } } diff --git a/backend/src/modules/payments/payments.controller.ts b/backend/src/modules/payments/payments.controller.ts index 6240b7c..cc4bca4 100644 --- a/backend/src/modules/payments/payments.controller.ts +++ b/backend/src/modules/payments/payments.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { PaymentsService } from './payments.service'; @ApiTags('payments') @@ -11,19 +12,24 @@ export class PaymentsController { constructor(private paymentsService: PaymentsService) {} @Get() + @RequireCapability('transactions.view') findAll() { return this.paymentsService.findAll(); } @Get(':id') + @RequireCapability('transactions.view') findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); } @Post() + @RequireCapability('transactions.edit') create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); } @Put(':id') + @RequireCapability('transactions.edit') update(@Param('id') id: string, @Body() dto: any, @Request() req: any) { return this.paymentsService.update(id, dto, req.user.sub); } @Delete(':id') + @RequireCapability('transactions.edit') delete(@Param('id') id: string) { return this.paymentsService.delete(id); } } diff --git a/backend/src/modules/projects/projects.controller.ts b/backend/src/modules/projects/projects.controller.ts index 2d8c2c9..207d5e4 100644 --- a/backend/src/modules/projects/projects.controller.ts +++ b/backend/src/modules/projects/projects.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { Response } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { ProjectsService } from './projects.service'; @ApiTags('projects') @@ -12,9 +13,11 @@ export class ProjectsController { constructor(private service: ProjectsService) {} @Get() + @RequireCapability('planning.projects.view') findAll() { return this.service.findAll(); } @Get('export') + @RequireCapability('planning.projects.view') async exportCSV(@Res() res: Response) { const csv = await this.service.exportCSV(); res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' }); @@ -22,21 +25,27 @@ export class ProjectsController { } @Get('planning') + @RequireCapability('planning.projects.view') findForPlanning() { return this.service.findForPlanning(); } @Get(':id') + @RequireCapability('planning.projects.view') findOne(@Param('id') id: string) { return this.service.findOne(id); } @Post('import') + @RequireCapability('planning.projects.edit') importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); } @Post() + @RequireCapability('planning.projects.edit') create(@Body() dto: any) { return this.service.create(dto); } @Put(':id') + @RequireCapability('planning.projects.edit') update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); } @Put(':id/planned-date') + @RequireCapability('planning.projects.edit') updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) { return this.service.updatePlannedDate(id, dto.planned_date); } diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index 9fc2294..4cf1f26 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { ReportsService } from './reports.service'; @ApiTags('reports') @@ -11,11 +12,13 @@ export class ReportsController { constructor(private reportsService: ReportsService) {} @Get('balance-sheet') + @RequireCapability('reports.view') getBalanceSheet(@Query('as_of') asOf?: string) { return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]); } @Get('income-statement') + @RequireCapability('reports.view') getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) { const now = new Date(); const defaultFrom = `${now.getFullYear()}-01-01`; @@ -24,6 +27,7 @@ export class ReportsController { } @Get('cash-flow-sankey') + @RequireCapability('reports.view') getCashFlowSankey( @Query('year') year?: string, @Query('source') source?: string, @@ -37,6 +41,7 @@ export class ReportsController { } @Get('cash-flow') + @RequireCapability('reports.view') getCashFlowStatement( @Query('from') from?: string, @Query('to') to?: string, @@ -51,26 +56,31 @@ export class ReportsController { } @Get('aging') + @RequireCapability('reports.view') getAgingReport() { return this.reportsService.getAgingReport(); } @Get('year-end') + @RequireCapability('reports.view') getYearEndSummary(@Query('year') year?: string) { return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear()); } @Get('dashboard') + @RequireCapability('reports.view') getDashboardKPIs() { return this.reportsService.getDashboardKPIs(); } @Get('upcoming-investment-activities') + @RequireCapability('reports.view') getUpcomingInvestmentActivities() { return this.reportsService.getUpcomingInvestmentActivities(); } @Get('cash-flow-forecast') + @RequireCapability('reports.view') getCashFlowForecast( @Query('startYear') startYear?: string, @Query('months') months?: string, @@ -81,6 +91,7 @@ export class ReportsController { } @Get('capital-planning') + @RequireCapability('reports.view') getCapitalPlanningReport(@Query('startYear') startYear?: string) { return this.reportsService.getCapitalPlanningReport( parseInt(startYear || '') || undefined, @@ -88,6 +99,7 @@ export class ReportsController { } @Get('quarterly') + @RequireCapability('reports.view') getQuarterlyFinancial( @Query('year') year?: string, @Query('quarter') quarter?: string, diff --git a/backend/src/modules/reserve-components/reserve-components.controller.ts b/backend/src/modules/reserve-components/reserve-components.controller.ts index d138b62..4c1c6a7 100644 --- a/backend/src/modules/reserve-components/reserve-components.controller.ts +++ b/backend/src/modules/reserve-components/reserve-components.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { ReserveComponentsService } from './reserve-components.service'; @ApiTags('reserve-components') @@ -11,14 +12,18 @@ export class ReserveComponentsController { constructor(private service: ReserveComponentsService) {} @Get() + @RequireCapability('planning.projects.view') findAll() { return this.service.findAll(); } @Get(':id') + @RequireCapability('planning.projects.view') findOne(@Param('id') id: string) { return this.service.findOne(id); } @Post() + @RequireCapability('planning.projects.edit') create(@Body() dto: any) { return this.service.create(dto); } @Put(':id') + @RequireCapability('planning.projects.edit') update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); } } diff --git a/backend/src/modules/units/units.controller.ts b/backend/src/modules/units/units.controller.ts index 68f7961..45280ba 100644 --- a/backend/src/modules/units/units.controller.ts +++ b/backend/src/modules/units/units.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { Response } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { UnitsService } from './units.service'; @ApiTags('units') @@ -12,9 +13,11 @@ export class UnitsController { constructor(private unitsService: UnitsService) {} @Get() + @RequireCapability('assessments.units.view') findAll() { return this.unitsService.findAll(); } @Get('export') + @RequireCapability('assessments.units.view') async exportCSV(@Res() res: Response) { const csv = await this.unitsService.exportCSV(); res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' }); @@ -22,17 +25,22 @@ export class UnitsController { } @Get(':id') + @RequireCapability('assessments.units.view') findOne(@Param('id') id: string) { return this.unitsService.findOne(id); } @Post('import') + @RequireCapability('assessments.units.edit') importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); } @Post() + @RequireCapability('assessments.units.edit') create(@Body() dto: any) { return this.unitsService.create(dto); } @Put(':id') + @RequireCapability('assessments.units.edit') update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); } @Delete(':id') + @RequireCapability('assessments.units.edit') delete(@Param('id') id: string) { return this.unitsService.delete(id); } } diff --git a/backend/src/modules/vendors/vendors.controller.ts b/backend/src/modules/vendors/vendors.controller.ts index f1a1d97..7a400c2 100644 --- a/backend/src/modules/vendors/vendors.controller.ts +++ b/backend/src/modules/vendors/vendors.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from ' import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { Response } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequireCapability } from '../../common/decorators/capability.decorator'; import { VendorsService } from './vendors.service'; @ApiTags('vendors') @@ -12,9 +13,11 @@ export class VendorsController { constructor(private vendorsService: VendorsService) {} @Get() + @RequireCapability('reference.vendors.view') findAll() { return this.vendorsService.findAll(); } @Get('export') + @RequireCapability('reference.vendors.view') async exportCSV(@Res() res: Response) { const csv = await this.vendorsService.exportCSV(); res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' }); @@ -22,19 +25,24 @@ export class VendorsController { } @Get('1099-data') + @RequireCapability('reference.vendors.view') get1099Data(@Query('year') year: string) { return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear()); } @Get(':id') + @RequireCapability('reference.vendors.view') findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); } @Post('import') + @RequireCapability('reference.vendors.edit') importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); } @Post() + @RequireCapability('reference.vendors.edit') create(@Body() dto: any) { return this.vendorsService.create(dto); } @Put(':id') + @RequireCapability('reference.vendors.edit') update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); } } diff --git a/db/init/00-init.sql b/db/init/00-init.sql index ec49f32..ec80547 100644 --- a/db/init/00-init.sql +++ b/db/init/00-init.sql @@ -58,7 +58,7 @@ CREATE TABLE shared.user_organizations ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE, - role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')), + role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')), is_active BOOLEAN DEFAULT TRUE, joined_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, organization_id) diff --git a/db/migrations/020-add-vice-president-role.sql b/db/migrations/020-add-vice-president-role.sql new file mode 100644 index 0000000..5d0ffa5 --- /dev/null +++ b/db/migrations/020-add-vice-president-role.sql @@ -0,0 +1,9 @@ +-- Migration 020: Add vice_president role to user_organizations +-- This adds the vice_president role to the CHECK constraint on the role column. + +ALTER TABLE shared.user_organizations + DROP CONSTRAINT IF EXISTS user_organizations_role_check; + +ALTER TABLE shared.user_organizations + ADD CONSTRAINT user_organizations_role_check + CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18fd4d1..7a76241 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,7 @@ import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentS import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage'; import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage'; import { PricingPage } from './pages/pricing/PricingPage'; +import { PermissionSettingsPage } from './pages/settings/PermissionSettingsPage'; import { OnboardingPage } from './pages/onboarding/OnboardingPage'; import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage'; @@ -182,6 +183,7 @@ export function App() { } /> } /> } /> + } /> ); diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 363570b..6b557ee 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -77,8 +77,9 @@ export function AppLayout() { navigate('/admin'); }; - // Tenant admins (president role) can manage org members - const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin'; + // Capability-based check: can this user manage members? + const capabilities = currentOrg?.capabilities || []; + const isTenantAdmin = user?.isSuperadmin || capabilities.includes('settings.members.manage'); return (