mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r0.10): apps/api — auth fermée par défaut, matrice en base, démo-login ADR-002, seed
- packages/shared : rôles/catégories, schémas Zod, contrat d'API ; pnpm contract → docs/openapi.json committée (règle d'or ADR-001) - apps/api : NestJS 11 + Prisma 6, migration r0_identity (Role/Permission/User) ; guard JWT global + @Public() ; PermissionsGuard (@RequirePermission, matrice relue en base, cache 60 s) ; FileStorage (seul import MinIO) ; /health - démo-login ADR-002 : module conditionnel DEMO_MODE (404 sinon, testé e2e), double verrou production, refus des comptes isDemo=false - seed idempotent : 7 rôles, matrice complète (70 lignes), 7 comptes démo - 19 tests Jest (unit + e2e) ; smoke test sur build de prod Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
41
apps/api/src/app.module.ts
Normal file
41
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { demoModeEnabled } from './config/env';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PermissionsGuard } from './permissions/permissions.guard';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
/**
|
||||
* Composé via forRoot() (et non statiquement) pour que l'enregistrement
|
||||
* conditionnel de DemoAuthModule (ADR-002) soit décidé au démarrage —
|
||||
* et testable dans les deux états (module présent / routes 404).
|
||||
*/
|
||||
@Module({})
|
||||
export class AppModule {
|
||||
static forRoot(): DynamicModule {
|
||||
return {
|
||||
module: AppModule,
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PermissionsModule,
|
||||
FilesModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
HealthModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
providers: [
|
||||
// Ordre significatif : authentification puis permissions
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
17
apps/api/src/auth/auth.controller.ts
Normal file
17
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||
import { LoginRequestSchema, type LoginRequest } from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from './public.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
||||
return this.authService.login(body.email, body.password);
|
||||
}
|
||||
}
|
||||
26
apps/api/src/auth/auth.module.ts
Normal file
26
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule, type JwtSignOptions } from '@nestjs/jwt';
|
||||
import { loadEnv } from '../config/env';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
useFactory: () => {
|
||||
const env = loadEnv();
|
||||
return {
|
||||
secret: env.JWT_SECRET,
|
||||
signOptions: {
|
||||
expiresIn: env.JWT_EXPIRES_IN as JwtSignOptions['expiresIn'],
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
104
apps/api/src/auth/auth.service.ts
Normal file
104
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type {
|
||||
AuthResponse,
|
||||
DemoAccountsResponse,
|
||||
RoleName,
|
||||
} from '@siop/shared';
|
||||
import * as argon2 from 'argon2';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type UserWithRole = {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
isDemo: boolean;
|
||||
role: { id: string; name: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async login(email: string, password: string): Promise<AuthResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: { role: true },
|
||||
});
|
||||
// argon2.verify même sur compte inconnu = temps de réponse homogène
|
||||
const valid =
|
||||
user?.passwordHash != null &&
|
||||
(await argon2.verify(user.passwordHash, password));
|
||||
if (!user || !user.isActive || !valid) {
|
||||
throw new UnauthorizedException('Identifiants invalides');
|
||||
}
|
||||
return this.issueToken(user);
|
||||
}
|
||||
|
||||
/** ADR-002 — liste publique en DEMO_MODE, jamais de secret. */
|
||||
async listDemoAccounts(): Promise<DemoAccountsResponse> {
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { isDemo: true, isActive: true },
|
||||
include: { role: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return {
|
||||
accounts: users.map((u) => ({
|
||||
id: u.id,
|
||||
displayName: u.displayName,
|
||||
roleName: u.role.name as RoleName,
|
||||
initials: this.initials(u.displayName),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** ADR-002 — refuse tout compte isDemo=false, quoi qu'il arrive. */
|
||||
async demoLogin(userId: string): Promise<AuthResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { role: true },
|
||||
});
|
||||
if (!user || !user.isActive) throw new NotFoundException('Compte inconnu');
|
||||
if (!user.isDemo) {
|
||||
throw new ForbiddenException(
|
||||
'Ce compte n’est pas un compte de démonstration',
|
||||
);
|
||||
}
|
||||
return this.issueToken(user);
|
||||
}
|
||||
|
||||
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
||||
// Identité seule — les droits restent en base (invariant R0)
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
sub: user.id,
|
||||
roleId: user.role.id,
|
||||
});
|
||||
return {
|
||||
accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: { id: user.role.id, name: user.role.name as RoleName },
|
||||
isDemo: user.isDemo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private initials(displayName: string): string {
|
||||
return displayName
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]!.toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
14
apps/api/src/auth/current-user.decorator.ts
Normal file
14
apps/api/src/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/** Identité portée par le JWT — jamais de droits (invariant R0) :
|
||||
* la matrice est relue en base par PermissionsGuard. */
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, ctx: ExecutionContext): AuthenticatedUser => {
|
||||
return ctx.switchToHttp().getRequest().user;
|
||||
},
|
||||
);
|
||||
27
apps/api/src/auth/demo/demo-auth.controller.ts
Normal file
27
apps/api/src/auth/demo/demo-auth.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import { DemoLoginRequestSchema, type DemoLoginRequest } from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../../common/zod-validation.pipe';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { Public } from '../public.decorator';
|
||||
|
||||
/** ADR-002 — ces routes n'existent que si DemoAuthModule est enregistré
|
||||
* (DEMO_MODE=true) ; sinon : 404, vérifié par test e2e. */
|
||||
@Controller('auth')
|
||||
export class DemoAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Get('demo-accounts')
|
||||
listDemoAccounts() {
|
||||
return this.authService.listDemoAccounts();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('demo-login')
|
||||
@HttpCode(200)
|
||||
demoLogin(
|
||||
@Body(new ZodValidationPipe(DemoLoginRequestSchema)) body: DemoLoginRequest,
|
||||
) {
|
||||
return this.authService.demoLogin(body.userId);
|
||||
}
|
||||
}
|
||||
27
apps/api/src/auth/demo/demo-auth.module.ts
Normal file
27
apps/api/src/auth/demo/demo-auth.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { assertDemoModeAllowed } from '../../config/env';
|
||||
import { AuthModule } from '../auth.module';
|
||||
import { DemoAuthController } from './demo-auth.controller';
|
||||
|
||||
/**
|
||||
* ADR-002 : module enregistré UNIQUEMENT quand DEMO_MODE=true
|
||||
* (voir AppModule.forRoot). Double verrou : refuse de démarrer en production
|
||||
* sans DEMO_MODE_I_KNOW=true.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [DemoAuthController],
|
||||
})
|
||||
export class DemoAuthModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(DemoAuthModule.name);
|
||||
|
||||
constructor() {
|
||||
assertDemoModeAllowed();
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.warn(
|
||||
'DEMO_MODE actif — /auth/demo-accounts et /auth/demo-login sont exposées.',
|
||||
);
|
||||
}
|
||||
}
|
||||
46
apps/api/src/auth/jwt-auth.guard.ts
Normal file
46
apps/api/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { Request } from 'express';
|
||||
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||
|
||||
/** Guard global : API fermée par défaut, ouverture explicite via @Public(). */
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const [scheme, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
throw new UnauthorizedException('Jeton manquant');
|
||||
}
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<{
|
||||
sub: string;
|
||||
roleId: string;
|
||||
}>(token);
|
||||
(request as Request & { user: unknown }).user = {
|
||||
userId: payload.sub,
|
||||
roleId: payload.roleId,
|
||||
};
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Jeton invalide ou expiré');
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/api/src/auth/public.decorator.ts
Normal file
7
apps/api/src/auth/public.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
/** L'API est fermée par défaut (guard JWT global) : toute route publique
|
||||
* doit l'être EXPLICITEMENT via ce décorateur. */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
22
apps/api/src/common/zod-validation.pipe.ts
Normal file
22
apps/api/src/common/zod-validation.pipe.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
|
||||
import type { z } from 'zod';
|
||||
|
||||
/** Valide un body contre un schéma du contrat (@siop/shared). */
|
||||
@Injectable()
|
||||
export class ZodValidationPipe implements PipeTransform {
|
||||
constructor(private readonly schema: z.ZodType) {}
|
||||
|
||||
transform(value: unknown) {
|
||||
const result = this.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({
|
||||
message: 'Corps de requête invalide',
|
||||
issues: result.error.issues.map((i) => ({
|
||||
path: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
37
apps/api/src/config/env.spec.ts
Normal file
37
apps/api/src/config/env.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { assertDemoModeAllowed, demoModeEnabled } from './env';
|
||||
|
||||
describe('ADR-002 — double verrou DEMO_MODE', () => {
|
||||
const saved = { ...process.env };
|
||||
afterEach(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
it('refuse DEMO_MODE=true en production sans DEMO_MODE_I_KNOW', () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.DEMO_MODE_I_KNOW;
|
||||
expect(() => assertDemoModeAllowed()).toThrow(/ADR-002/);
|
||||
});
|
||||
|
||||
it('accepte DEMO_MODE=true en production AVEC DEMO_MODE_I_KNOW=true (instance démo)', () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.DEMO_MODE_I_KNOW = 'true';
|
||||
expect(() => assertDemoModeAllowed()).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepte DEMO_MODE=true hors production', () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
process.env.NODE_ENV = 'development';
|
||||
expect(() => assertDemoModeAllowed()).not.toThrow();
|
||||
});
|
||||
|
||||
it("demoModeEnabled ne s'active que sur la valeur exacte « true »", () => {
|
||||
process.env.DEMO_MODE = '1';
|
||||
expect(demoModeEnabled()).toBe(false);
|
||||
process.env.DEMO_MODE = 'true';
|
||||
expect(demoModeEnabled()).toBe(true);
|
||||
delete process.env.DEMO_MODE;
|
||||
expect(demoModeEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
52
apps/api/src/config/env.ts
Normal file
52
apps/api/src/config/env.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dotenv/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
JWT_SECRET: z.string().min(16, 'JWT_SECRET : 16 caractères minimum'),
|
||||
JWT_EXPIRES_IN: z.string().default('8h'),
|
||||
MINIO_ENDPOINT: z.string().default('localhost'),
|
||||
MINIO_PORT: z.coerce.number().default(9000),
|
||||
MINIO_USE_SSL: z.string().optional(),
|
||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
/** Validée au démarrage — l'API refuse de booter sur une config invalide. */
|
||||
export function loadEnv(): Env {
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Configuration invalide :\n${parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** Lues à l'appel (et non à l'import) pour rester testables. */
|
||||
export function demoModeEnabled(): boolean {
|
||||
return process.env.DEMO_MODE === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-002, double verrou : DEMO_MODE=true en production exige l'aveu explicite
|
||||
* DEMO_MODE_I_KNOW=true (réservé à l'instance de démonstration publique).
|
||||
*/
|
||||
export function assertDemoModeAllowed(): void {
|
||||
if (
|
||||
demoModeEnabled() &&
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
process.env.DEMO_MODE_I_KNOW !== 'true'
|
||||
) {
|
||||
throw new Error(
|
||||
'DEMO_MODE=true en production sans DEMO_MODE_I_KNOW=true — démarrage refusé (ADR-002).',
|
||||
);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/files/file-storage.ts
Normal file
12
apps/api/src/files/file-storage.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
|
||||
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
|
||||
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
|
||||
* les opérations arrivent avec les photos/documents (R2-R3).
|
||||
*/
|
||||
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||
|
||||
export interface FileStorage {
|
||||
/** Lève une exception si le stockage est injoignable. */
|
||||
healthCheck(): Promise<void>;
|
||||
}
|
||||
10
apps/api/src/files/files.module.ts
Normal file
10
apps/api/src/files/files.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { FILE_STORAGE } from './file-storage';
|
||||
import { MinioStorageService } from './minio-storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: FILE_STORAGE, useClass: MinioStorageService }],
|
||||
exports: [FILE_STORAGE],
|
||||
})
|
||||
export class FilesModule {}
|
||||
25
apps/api/src/files/minio-storage.service.ts
Normal file
25
apps/api/src/files/minio-storage.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as Minio from 'minio';
|
||||
import { loadEnv } from '../config/env';
|
||||
import type { FileStorage } from './file-storage';
|
||||
|
||||
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */
|
||||
@Injectable()
|
||||
export class MinioStorageService implements FileStorage {
|
||||
private readonly client: Minio.Client;
|
||||
|
||||
constructor() {
|
||||
const env = loadEnv();
|
||||
this.client = new Minio.Client({
|
||||
endPoint: env.MINIO_ENDPOINT,
|
||||
port: env.MINIO_PORT,
|
||||
useSSL: env.MINIO_USE_SSL === 'true',
|
||||
accessKey: env.MINIO_ACCESS_KEY,
|
||||
secretKey: env.MINIO_SECRET_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<void> {
|
||||
await this.client.listBuckets();
|
||||
}
|
||||
}
|
||||
53
apps/api/src/health/health.controller.ts
Normal file
53
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import type { HealthResponse } from '@siop/shared';
|
||||
import Redis from 'ioredis';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { loadEnv } from '../config/env';
|
||||
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
async health(): Promise<HealthResponse> {
|
||||
const [database, redis, storage] = await Promise.all([
|
||||
this.check(() => this.prisma.$queryRaw`SELECT 1`),
|
||||
this.check(async () => {
|
||||
const client = new Redis(loadEnv().REDIS_URL, {
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 0,
|
||||
connectTimeout: 2000,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
await client.ping();
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
}),
|
||||
this.check(() => this.storage.healthCheck()),
|
||||
]);
|
||||
const services = { database, redis, storage };
|
||||
return {
|
||||
status: Object.values(services).every((s) => s === 'up')
|
||||
? 'ok'
|
||||
: 'degraded',
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
private async check(fn: () => Promise<unknown>): Promise<'up' | 'down'> {
|
||||
try {
|
||||
await fn();
|
||||
return 'up';
|
||||
} catch {
|
||||
return 'down';
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/api/src/health/health.module.ts
Normal file
7
apps/api/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
17
apps/api/src/main.ts
Normal file
17
apps/api/src/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'reflect-metadata';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { assertDemoModeAllowed, loadEnv } from './config/env';
|
||||
|
||||
async function bootstrap() {
|
||||
const env = loadEnv();
|
||||
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
||||
|
||||
const app = await NestFactory.create(AppModule.forRoot());
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(env.PORT);
|
||||
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
48
apps/api/src/permissions/permissions.guard.ts
Normal file
48
apps/api/src/permissions/permissions.guard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
PERMISSION_KEY,
|
||||
RequiredPermission,
|
||||
} from './require-permission.decorator';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
|
||||
/** Second guard global (après JwtAuthGuard) : applique @RequirePermission
|
||||
* en relisant la matrice EN BASE — le JWT ne porte jamais de droits. */
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required = this.reflector.getAllAndOverride<RequiredPermission>(
|
||||
PERMISSION_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!required) return true; // pas d'exigence déclarée (ex. routes @Public)
|
||||
|
||||
const user: AuthenticatedUser | undefined = context
|
||||
.switchToHttp()
|
||||
.getRequest().user;
|
||||
if (!user) throw new ForbiddenException();
|
||||
|
||||
const allowed = await this.permissionsService.can(
|
||||
user.roleId,
|
||||
required.category,
|
||||
required.right,
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Droit manquant : ${required.category}.${required.right}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
apps/api/src/permissions/permissions.module.ts
Normal file
9
apps/api/src/permissions/permissions.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PermissionsService],
|
||||
exports: [PermissionsService],
|
||||
})
|
||||
export class PermissionsModule {}
|
||||
62
apps/api/src/permissions/permissions.service.spec.ts
Normal file
62
apps/api/src/permissions/permissions.service.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
|
||||
const row = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||
id: 'p1',
|
||||
roleId: 'role-1',
|
||||
objectCategory: 'WORK_ORDERS',
|
||||
canView: true,
|
||||
canViewOther: false,
|
||||
canCreate: false,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('PermissionsService (matrice en base, cache 60 s)', () => {
|
||||
let findMany: jest.Mock;
|
||||
let service: PermissionsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
findMany = jest.fn().mockResolvedValue([row()]);
|
||||
service = new PermissionsService({
|
||||
permission: { findMany },
|
||||
} as unknown as PrismaService);
|
||||
});
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('applique les cinq droits de la ligne', async () => {
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'view')).resolves.toBe(true);
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'edit')).resolves.toBe(true);
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'viewOther')).resolves.toBe(false);
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'create')).resolves.toBe(false);
|
||||
await expect(service.can('role-1', 'WORK_ORDERS', 'delete')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('refuse une catégorie sans ligne (fermé par défaut)', async () => {
|
||||
await expect(service.can('role-1', 'SETTINGS', 'view')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('ne relit la base qu’à l’expiration du cache (60 s)', async () => {
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
await service.can('role-1', 'WORK_ORDERS', 'edit');
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(59_000);
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(2_000);
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidate() force la relecture immédiate', async () => {
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
service.invalidate('role-1');
|
||||
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
66
apps/api/src/permissions/permissions.service.ts
Normal file
66
apps/api/src/permissions/permissions.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
ObjectCategory,
|
||||
PermissionEntry,
|
||||
PermissionRight,
|
||||
} from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const CACHE_TTL_MS = 60_000; // invariant R0 : la matrice est relue au plus toutes les 60 s
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number;
|
||||
permissions: PermissionEntry[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsService {
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getForRole(roleId: string): Promise<PermissionEntry[]> {
|
||||
const cached = this.cache.get(roleId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.permissions;
|
||||
|
||||
const rows = await this.prisma.permission.findMany({ where: { roleId } });
|
||||
const permissions = rows.map((r) => ({
|
||||
objectCategory: r.objectCategory as ObjectCategory,
|
||||
canView: r.canView,
|
||||
canViewOther: r.canViewOther,
|
||||
canCreate: r.canCreate,
|
||||
canEdit: r.canEdit,
|
||||
canDelete: r.canDelete,
|
||||
}));
|
||||
this.cache.set(roleId, { expiresAt: Date.now() + CACHE_TTL_MS, permissions });
|
||||
return permissions;
|
||||
}
|
||||
|
||||
async can(
|
||||
roleId: string,
|
||||
category: ObjectCategory,
|
||||
right: PermissionRight,
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.getForRole(roleId);
|
||||
const entry = permissions.find((p) => p.objectCategory === category);
|
||||
if (!entry) return false;
|
||||
switch (right) {
|
||||
case 'view':
|
||||
return entry.canView;
|
||||
case 'viewOther':
|
||||
return entry.canViewOther;
|
||||
case 'create':
|
||||
return entry.canCreate;
|
||||
case 'edit':
|
||||
return entry.canEdit;
|
||||
case 'delete':
|
||||
return entry.canDelete;
|
||||
}
|
||||
}
|
||||
|
||||
/** À appeler après toute modification de la matrice (admin, R1+). */
|
||||
invalidate(roleId?: string): void {
|
||||
if (roleId) this.cache.delete(roleId);
|
||||
else this.cache.clear();
|
||||
}
|
||||
}
|
||||
16
apps/api/src/permissions/require-permission.decorator.ts
Normal file
16
apps/api/src/permissions/require-permission.decorator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||
|
||||
export const PERMISSION_KEY = 'requiredPermission';
|
||||
|
||||
export interface RequiredPermission {
|
||||
category: ObjectCategory;
|
||||
right: PermissionRight;
|
||||
}
|
||||
|
||||
/** Exige un droit de la matrice (relue en base, cache 60 s) :
|
||||
* @RequirePermission('WORK_ORDERS', 'create') */
|
||||
export const RequirePermission = (
|
||||
category: ObjectCategory,
|
||||
right: PermissionRight,
|
||||
) => SetMetadata(PERMISSION_KEY, { category, right } satisfies RequiredPermission);
|
||||
9
apps/api/src/prisma/prisma.module.ts
Normal file
9
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
16
apps/api/src/prisma/prisma.service.ts
Normal file
16
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
34
apps/api/src/users/users.controller.ts
Normal file
34
apps/api/src/users/users.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
||||
import type { MeResponse, RoleName } from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */
|
||||
@Get('me')
|
||||
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: current.userId },
|
||||
include: { role: true },
|
||||
});
|
||||
if (!user || !user.isActive) throw new NotFoundException();
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: { id: user.role.id, name: user.role.name as RoleName },
|
||||
isDemo: user.isDemo,
|
||||
permissions: await this.permissionsService.getForRole(user.roleId),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/api/src/users/users.module.ts
Normal file
7
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
})
|
||||
export class UsersModule {}
|
||||
Reference in New Issue
Block a user