RBAC: Enforce read-only viewer role across backend and frontend
- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role - Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations) - Add useIsReadOnly hook to auth store for frontend role checks - Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers - Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers - Skip onboarding wizard for viewer role users Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
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 { AuthModule } from './modules/auth/auth.module';
|
||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
@@ -64,6 +66,12 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
|
||||
InvestmentPlanningModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: WriteAccessGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
4
backend/src/common/decorators/allow-viewer.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ALLOW_VIEWER_KEY = 'allowViewer';
|
||||
export const AllowViewer = () => SetMetadata(ALLOW_VIEWER_KEY, true);
|
||||
34
backend/src/common/guards/write-access.guard.ts
Normal file
34
backend/src/common/guards/write-access.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ALLOW_VIEWER_KEY } from '../decorators/allow-viewer.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class WriteAccessGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const method = request.method;
|
||||
|
||||
// Allow all read methods
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return true;
|
||||
|
||||
// If no user on request (unauthenticated endpoints like login/register), allow
|
||||
const user = request.user;
|
||||
if (!user) return true;
|
||||
|
||||
// Check for @AllowViewer() exemption on handler or class
|
||||
const allowViewer = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (allowViewer) return true;
|
||||
|
||||
// Block viewer role from write operations
|
||||
if (user.role === 'viewer') {
|
||||
throw new ForbiddenException('Read-only users cannot modify data');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { SwitchOrgDto } from './dto/switch-org.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -47,6 +48,7 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AllowViewer()
|
||||
async markIntroSeen(@Request() req: any) {
|
||||
await this.authService.markIntroSeen(req.user.sub);
|
||||
return { success: true };
|
||||
@@ -56,6 +58,7 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Switch active organization' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AllowViewer()
|
||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||
const ua = req.headers['user-agent'];
|
||||
|
||||
@@ -1,6 +1,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 { InvestmentPlanningService } from './investment-planning.service';
|
||||
|
||||
@ApiTags('investment-planning')
|
||||
@@ -36,6 +37,7 @@ export class InvestmentPlanningController {
|
||||
|
||||
@Post('recommendations')
|
||||
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
||||
@AllowViewer()
|
||||
getRecommendations(@Req() req: any) {
|
||||
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user