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:
pr-daaif
2026-07-15 21:55:04 +01:00
parent ddc9dc52b5
commit 8adb561b63
62 changed files with 8509 additions and 2 deletions

View 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);
}
}

View 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 {}

View 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 nest 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('');
}
}

View 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;
},
);

View 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);
}
}

View 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.',
);
}
}

View 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é');
}
}
}

View 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);