Major Enhancement: NestJS Migration + CASL Authorization + Error Handling
Some checks failed
CI/CD Pipeline / Backend Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Tests (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
Some checks failed
CI/CD Pipeline / Backend Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Tests (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
Complete rewrite from Express to NestJS with enterprise-grade features: ## Backend Improvements - Migrated from Express to NestJS 11.0.1 with TypeScript - Implemented Prisma ORM 7.3.0 for type-safe database access - Added CASL authorization system replacing role-based guards - Created global exception filters with structured logging - Implemented Auth0 JWT authentication with Passport.js - Added vehicle management with conflict detection - Enhanced event scheduling with driver/vehicle assignment - Comprehensive error handling and logging ## Frontend Improvements - Upgraded to React 19.2.0 with Vite 7.2.4 - Implemented CASL-based permission system - Added AbilityContext for declarative permissions - Created ErrorHandler utility for consistent error messages - Enhanced API client with request/response logging - Added War Room (Command Center) dashboard - Created VIP Schedule view with complete itineraries - Implemented Vehicle Management UI - Added mock data generators for testing (288 events across 20 VIPs) ## New Features - Vehicle fleet management (types, capacity, status tracking) - Complete 3-day Jamboree schedule generation - Individual VIP schedule pages with PDF export (planned) - Real-time War Room dashboard with auto-refresh - Permission-based navigation filtering - First user auto-approval as administrator ## Documentation - Created CASL_AUTHORIZATION.md (comprehensive guide) - Created ERROR_HANDLING.md (error handling patterns) - Updated CLAUDE.md with new architecture - Added migration guides and best practices ## Technical Debt Resolved - Removed custom authentication in favor of Auth0 - Replaced role checks with CASL abilities - Standardized error responses across API - Implemented proper TypeScript typing - Added comprehensive logging Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
90
backend/src/auth/abilities/ability.factory.ts
Normal file
90
backend/src/auth/abilities/ability.factory.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { AbilityBuilder, PureAbility, AbilityClass, ExtractSubjectType, InferSubjects } from '@casl/ability';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Role, User, VIP, Driver, ScheduleEvent, Flight, Vehicle } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Define all possible actions in the system
|
||||
*/
|
||||
export enum Action {
|
||||
Manage = 'manage', // Special: allows everything
|
||||
Create = 'create',
|
||||
Read = 'read',
|
||||
Update = 'update',
|
||||
Delete = 'delete',
|
||||
Approve = 'approve', // Special: for user approval
|
||||
UpdateStatus = 'update-status', // Special: for drivers to update event status
|
||||
}
|
||||
|
||||
/**
|
||||
* Define all subjects (resources) in the system
|
||||
*/
|
||||
export type Subjects =
|
||||
| InferSubjects<typeof User | typeof VIP | typeof Driver | typeof ScheduleEvent | typeof Flight | typeof Vehicle>
|
||||
| 'User'
|
||||
| 'VIP'
|
||||
| 'Driver'
|
||||
| 'ScheduleEvent'
|
||||
| 'Flight'
|
||||
| 'Vehicle'
|
||||
| 'all';
|
||||
|
||||
/**
|
||||
* Define the AppAbility type
|
||||
*/
|
||||
export type AppAbility = PureAbility<[Action, Subjects]>;
|
||||
|
||||
@Injectable()
|
||||
export class AbilityFactory {
|
||||
/**
|
||||
* Define abilities for a user based on their role
|
||||
*/
|
||||
defineAbilitiesFor(user: User): AppAbility {
|
||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
|
||||
PureAbility as AbilityClass<AppAbility>,
|
||||
);
|
||||
|
||||
// Define permissions based on role
|
||||
if (user.role === Role.ADMINISTRATOR) {
|
||||
// Administrators can do everything
|
||||
can(Action.Manage, 'all');
|
||||
} else if (user.role === Role.COORDINATOR) {
|
||||
// Coordinators have full access except user management
|
||||
can(Action.Read, 'all');
|
||||
can(Action.Create, ['VIP', 'Driver', 'ScheduleEvent', 'Flight', 'Vehicle']);
|
||||
can(Action.Update, ['VIP', 'Driver', 'ScheduleEvent', 'Flight', 'Vehicle']);
|
||||
can(Action.Delete, ['VIP', 'Driver', 'ScheduleEvent', 'Flight', 'Vehicle']);
|
||||
|
||||
// Cannot manage users
|
||||
cannot(Action.Create, 'User');
|
||||
cannot(Action.Update, 'User');
|
||||
cannot(Action.Delete, 'User');
|
||||
cannot(Action.Approve, 'User');
|
||||
} else if (user.role === Role.DRIVER) {
|
||||
// Drivers can only read most resources
|
||||
can(Action.Read, ['VIP', 'Driver', 'ScheduleEvent', 'Vehicle']);
|
||||
|
||||
// Drivers can update status of their own events
|
||||
can(Action.UpdateStatus, 'ScheduleEvent', { driverId: user.driver?.id });
|
||||
|
||||
// Cannot access flights
|
||||
cannot(Action.Read, 'Flight');
|
||||
|
||||
// Cannot access users
|
||||
cannot(Action.Read, 'User');
|
||||
}
|
||||
|
||||
return build({
|
||||
// Detect subject type from object
|
||||
detectSubjectType: (item) =>
|
||||
item.constructor as ExtractSubjectType<Subjects>,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform action on subject
|
||||
*/
|
||||
canUserPerform(user: User, action: Action, subject: Subjects): boolean {
|
||||
const ability = this.defineAbilitiesFor(user);
|
||||
return ability.can(action, subject);
|
||||
}
|
||||
}
|
||||
17
backend/src/auth/auth.controller.ts
Normal file
17
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getProfile(@CurrentUser() user: User) {
|
||||
// Return user profile (password already excluded by Prisma)
|
||||
return user;
|
||||
}
|
||||
}
|
||||
30
backend/src/auth/auth.module.ts
Normal file
30
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { AbilityFactory } from './abilities/ability.factory';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET') || 'development-secret-key',
|
||||
signOptions: {
|
||||
expiresIn: '7d',
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, AbilityFactory],
|
||||
exports: [AuthService, PassportModule, JwtModule, AbilityFactory],
|
||||
})
|
||||
export class AuthModule {}
|
||||
66
backend/src/auth/auth.service.ts
Normal file
66
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Validate and get/create user from Auth0 token payload
|
||||
*/
|
||||
async validateUser(payload: any) {
|
||||
const namespace = 'https://vip-coordinator-api';
|
||||
const auth0Id = payload.sub;
|
||||
const email = payload[`${namespace}/email`] || payload.email || `${auth0Id}@auth0.local`;
|
||||
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
|
||||
const picture = payload[`${namespace}/picture`] || payload.picture;
|
||||
|
||||
// Check if user exists
|
||||
let user = await this.prisma.user.findUnique({
|
||||
where: { auth0Id },
|
||||
include: { driver: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Check if this is the first user (auto-approve as admin)
|
||||
const userCount = await this.prisma.user.count();
|
||||
const isFirstUser = userCount === 0;
|
||||
|
||||
this.logger.log(
|
||||
`Creating new user: ${email} (isFirstUser: ${isFirstUser})`,
|
||||
);
|
||||
|
||||
// Create new user
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
auth0Id,
|
||||
email,
|
||||
name,
|
||||
picture,
|
||||
role: isFirstUser ? Role.ADMINISTRATOR : Role.DRIVER,
|
||||
isApproved: isFirstUser, // Auto-approve first user
|
||||
},
|
||||
include: { driver: true },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`User created: ${user.email} with role ${user.role} (approved: ${user.isApproved})`,
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
*/
|
||||
async getCurrentUser(auth0Id: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { auth0Id },
|
||||
include: { driver: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
39
backend/src/auth/decorators/check-ability.decorator.ts
Normal file
39
backend/src/auth/decorators/check-ability.decorator.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Action, Subjects } from '../abilities/ability.factory';
|
||||
import { CHECK_ABILITY, RequiredPermission } from '../guards/abilities.guard';
|
||||
|
||||
/**
|
||||
* Decorator to check CASL abilities on a route
|
||||
*
|
||||
* @example
|
||||
* @CheckAbilities({ action: Action.Create, subject: 'VIP' })
|
||||
* async create(@Body() dto: CreateVIPDto) {
|
||||
* return this.service.create(dto);
|
||||
* }
|
||||
*
|
||||
* @example Multiple permissions (all must be satisfied)
|
||||
* @CheckAbilities(
|
||||
* { action: Action.Read, subject: 'VIP' },
|
||||
* { action: Action.Update, subject: 'VIP' }
|
||||
* )
|
||||
*/
|
||||
export const CheckAbilities = (...permissions: RequiredPermission[]) =>
|
||||
SetMetadata(CHECK_ABILITY, permissions);
|
||||
|
||||
/**
|
||||
* Helper functions for common permission checks
|
||||
*/
|
||||
export const CanCreate = (subject: Subjects) =>
|
||||
CheckAbilities({ action: Action.Create, subject });
|
||||
|
||||
export const CanRead = (subject: Subjects) =>
|
||||
CheckAbilities({ action: Action.Read, subject });
|
||||
|
||||
export const CanUpdate = (subject: Subjects) =>
|
||||
CheckAbilities({ action: Action.Update, subject });
|
||||
|
||||
export const CanDelete = (subject: Subjects) =>
|
||||
CheckAbilities({ action: Action.Delete, subject });
|
||||
|
||||
export const CanManage = (subject: Subjects) =>
|
||||
CheckAbilities({ action: Action.Manage, subject });
|
||||
8
backend/src/auth/decorators/current-user.decorator.ts
Normal file
8
backend/src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
4
backend/src/auth/decorators/public.decorator.ts
Normal file
4
backend/src/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
5
backend/src/auth/decorators/roles.decorator.ts
Normal file
5
backend/src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
64
backend/src/auth/guards/abilities.guard.ts
Normal file
64
backend/src/auth/guards/abilities.guard.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AbilityFactory, Action, Subjects } from '../abilities/ability.factory';
|
||||
|
||||
/**
|
||||
* Interface for required permissions
|
||||
*/
|
||||
export interface RequiredPermission {
|
||||
action: Action;
|
||||
subject: Subjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata key for permissions
|
||||
*/
|
||||
export const CHECK_ABILITY = 'check_ability';
|
||||
|
||||
/**
|
||||
* Guard that checks CASL abilities
|
||||
*/
|
||||
@Injectable()
|
||||
export class AbilitiesGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private abilityFactory: AbilityFactory,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions =
|
||||
this.reflector.get<RequiredPermission[]>(
|
||||
CHECK_ABILITY,
|
||||
context.getHandler(),
|
||||
) || [];
|
||||
|
||||
// If no permissions required, allow access
|
||||
if (requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// User should be attached by JwtAuthGuard
|
||||
if (!user) {
|
||||
throw new ForbiddenException('User not authenticated');
|
||||
}
|
||||
|
||||
// Build abilities for user
|
||||
const ability = this.abilityFactory.defineAbilitiesFor(user);
|
||||
|
||||
// Check if user has all required permissions
|
||||
const hasPermission = requiredPermissions.every((permission) =>
|
||||
ability.can(permission.action, permission.subject),
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new ForbiddenException(
|
||||
`User does not have required permissions`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
25
backend/src/auth/guards/jwt-auth.guard.ts
Normal file
25
backend/src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
// Check if route is marked as public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
23
backend/src/auth/guards/roles.guard.ts
Normal file
23
backend/src/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from '@prisma/client';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user.role === role);
|
||||
}
|
||||
}
|
||||
75
backend/src/auth/strategies/jwt.strategy.ts
Normal file
75
backend/src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Injectable, UnauthorizedException, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||
import { passportJwtSecret } from 'jwks-rsa';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
private readonly logger = new Logger(JwtStrategy.name);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
private httpService: HttpService,
|
||||
) {
|
||||
super({
|
||||
secretOrKeyProvider: passportJwtSecret({
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 5,
|
||||
jwksUri: `${configService.get('AUTH0_ISSUER')}.well-known/jwks.json`,
|
||||
}),
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
audience: configService.get('AUTH0_AUDIENCE'),
|
||||
issuer: configService.get('AUTH0_ISSUER'),
|
||||
algorithms: ['RS256'],
|
||||
passReqToCallback: true, // We need the request to get the token
|
||||
});
|
||||
}
|
||||
|
||||
async validate(req: any, payload: any) {
|
||||
// Extract token from Authorization header
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
// Fetch user info from Auth0 /userinfo endpoint
|
||||
try {
|
||||
const userInfoUrl = `${this.configService.get('AUTH0_ISSUER')}userinfo`;
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(userInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Merge userinfo data into payload
|
||||
const userInfo = response.data;
|
||||
|
||||
payload.email = userInfo.email || payload.email;
|
||||
payload.name = userInfo.name || payload.name;
|
||||
payload.picture = userInfo.picture || payload.picture;
|
||||
payload.email_verified = userInfo.email_verified;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to fetch user info: ${error.message}`);
|
||||
// Continue with payload-only data (fallbacks will apply)
|
||||
}
|
||||
|
||||
// Get or create user from Auth0 token
|
||||
const user = await this.authService.validateUser(payload);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
if (!user.isApproved) {
|
||||
throw new UnauthorizedException('User account pending approval');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user