Some checks failed
CD Preprod / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
CD Preprod / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
CD Preprod / Integration Tests (push) Blocked by required conditions
CD Preprod / Build & Push Backend (push) Blocked by required conditions
CD Preprod / Build & Push Frontend (push) Blocked by required conditions
CD Preprod / Deploy to Preprod (push) Blocked by required conditions
CD Preprod / Smoke Tests (push) Blocked by required conditions
CD Preprod / Deployment Summary (push) Blocked by required conditions
CD Preprod / Notify Success (push) Blocked by required conditions
CD Preprod / Notify Failure (push) Blocked by required conditions
CD Preprod / Quality (${{ matrix.app }}) (backend) (push) Has been cancelled
CD Preprod / Quality (${{ matrix.app }}) (frontend) (push) Has been cancelled
Aligns preprod with the complete application codebase (cicd branch). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
import {
|
|
Injectable,
|
|
CanActivate,
|
|
ExecutionContext,
|
|
ForbiddenException,
|
|
Inject,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { PlanFeature } from '@domain/value-objects/plan-feature.vo';
|
|
import {
|
|
SubscriptionRepository,
|
|
SUBSCRIPTION_REPOSITORY,
|
|
} from '@domain/ports/out/subscription.repository';
|
|
import { REQUIRED_FEATURES_KEY } from '../decorators/requires-feature.decorator';
|
|
|
|
/**
|
|
* Feature Flag Guard
|
|
*
|
|
* Checks if the user's subscription plan includes the required features.
|
|
* First tries to read plan from JWT payload (fast path), falls back to DB lookup.
|
|
*
|
|
* Usage:
|
|
* @UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
|
|
* @RequiresFeature('dashboard')
|
|
*/
|
|
@Injectable()
|
|
export class FeatureFlagGuard implements CanActivate {
|
|
private readonly logger = new Logger(FeatureFlagGuard.name);
|
|
|
|
constructor(
|
|
private readonly reflector: Reflector,
|
|
@Inject(SUBSCRIPTION_REPOSITORY)
|
|
private readonly subscriptionRepository: SubscriptionRepository
|
|
) {}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
// Get required features from @RequiresFeature() decorator
|
|
const requiredFeatures = this.reflector.getAllAndOverride<PlanFeature[]>(
|
|
REQUIRED_FEATURES_KEY,
|
|
[context.getHandler(), context.getClass()]
|
|
);
|
|
|
|
// If no features are required, allow access
|
|
if (!requiredFeatures || requiredFeatures.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const request = context.switchToHttp().getRequest();
|
|
const user = request.user;
|
|
|
|
if (!user || !user.organizationId) {
|
|
return false;
|
|
}
|
|
|
|
// ADMIN users have full access to all features — no plan check needed
|
|
if (user.role === 'ADMIN') {
|
|
return true;
|
|
}
|
|
|
|
// Fast path: check plan features from JWT payload
|
|
if (user.planFeatures && Array.isArray(user.planFeatures)) {
|
|
const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature));
|
|
|
|
if (hasAllFeatures) {
|
|
return true;
|
|
}
|
|
|
|
// JWT says no — but JWT might be stale after an upgrade.
|
|
// Fall through to DB check.
|
|
}
|
|
|
|
// Slow path: DB lookup for fresh subscription data
|
|
try {
|
|
const subscription = await this.subscriptionRepository.findByOrganizationId(
|
|
user.organizationId
|
|
);
|
|
|
|
if (!subscription) {
|
|
// No subscription means Bronze (free) plan — no premium features
|
|
this.throwFeatureRequired(requiredFeatures);
|
|
}
|
|
|
|
const plan = subscription!.plan;
|
|
const missingFeatures = requiredFeatures.filter(feature => !plan.hasFeature(feature));
|
|
|
|
if (missingFeatures.length > 0) {
|
|
this.throwFeatureRequired(requiredFeatures);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof ForbiddenException) {
|
|
throw error;
|
|
}
|
|
this.logger.error(`Failed to check subscription features: ${error}`);
|
|
// On DB error, deny access to premium features rather than 500
|
|
this.throwFeatureRequired(requiredFeatures);
|
|
}
|
|
}
|
|
|
|
private throwFeatureRequired(features: PlanFeature[]): never {
|
|
const featureNames = features.join(', ');
|
|
throw new ForbiddenException(
|
|
`Cette fonctionnalité nécessite un plan supérieur. Fonctionnalités requises : ${featureNames}. Passez à un plan Silver ou supérieur.`
|
|
);
|
|
}
|
|
}
|