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

20
apps/api/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Copier en .env pour le dev local (valeurs alignées sur infra/docker-compose.yml).
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://siop:siop@localhost:5432/siop
REDIS_URL=redis://localhost:6379
# 16 caractères minimum — générer un vrai secret hors dev : openssl rand -hex 32
JWT_SECRET=dev-only-secret-0123456789abcdef
JWT_EXPIRES_IN=8h
# ADR-002 — sélecteur de compte démo. En production : ABSENT.
# (DEMO_MODE=true + NODE_ENV=production exige DEMO_MODE_I_KNOW=true.)
DEMO_MODE=true
# Mot de passe commun des comptes seedés (connexion classique) :
SEED_DEMO_PASSWORD=Demo!2026
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=siop
MINIO_SECRET_KEY=siop-minio

14
apps/api/jest.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['<rootDir>/src/**/*.spec.ts', '<rootDir>/test/**/*.e2e-spec.ts'],
moduleNameMapper: {
// Les tests consomment la source du contrat, pas le dist
'^@siop/shared$': '<rootDir>/../../packages/shared/src/index.ts',
},
setupFiles: ['<rootDir>/test/setup-env.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/main.ts'],
testTimeout: 30000,
};

9
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"tsConfigPath": "tsconfig.build.json",
"deleteOutDir": true
}
}

49
apps/api/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "@siop/api",
"version": "0.1.0",
"private": true,
"description": "SIOP V2 — API NestJS (auth fermée par défaut, matrice de permissions en base, démo-login ADR-002)",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest",
"test:cov": "jest --coverage",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.0",
"@nestjs/core": "^11.1.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.1.0",
"@prisma/client": "^6.8.0",
"@siop/shared": "workspace:*",
"argon2": "^0.43.0",
"dotenv": "^16.4.5",
"ioredis": "^5.4.0",
"minio": "^8.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^4.0.0"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^24.0.0",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"prisma": "^6.8.0",
"supertest": "^7.0.0",
"ts-jest": "^29.3.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}
}

View File

@@ -0,0 +1,54 @@
-- CreateTable
CREATE TABLE "Role" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" UUID NOT NULL,
"roleId" UUID NOT NULL,
"objectCategory" TEXT NOT NULL,
"canView" BOOLEAN NOT NULL DEFAULT false,
"canViewOther" BOOLEAN NOT NULL DEFAULT false,
"canCreate" BOOLEAN NOT NULL DEFAULT false,
"canEdit" BOOLEAN NOT NULL DEFAULT false,
"canDelete" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" UUID NOT NULL,
"email" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"passwordHash" TEXT,
"roleId" UUID NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isDemo" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_roleId_objectCategory_key" ON "Permission"("roleId", "objectCategory");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_roleId_idx" ON "User"("roleId");
-- AddForeignKey
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,47 @@
// Modèle R0 — identité & permissions (docs/03-architecture/modele-donnees.md).
// La matrice rôles × objets × droits vit EN BASE ; le JWT ne porte jamais de droits.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Role {
id String @id @default(uuid()) @db.Uuid
name String @unique // 7 rôles seedés — voir @siop/shared ROLE_NAMES
users User[]
permissions Permission[]
}
model Permission {
id String @id @default(uuid()) @db.Uuid
roleId String @db.Uuid
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
objectCategory String // enum applicatif — voir @siop/shared OBJECT_CATEGORIES
canView Boolean @default(false)
canViewOther Boolean @default(false) // « voir autre » : au-delà de ses propres objets
canCreate Boolean @default(false)
canEdit Boolean @default(false)
canDelete Boolean @default(false)
@@unique([roleId, objectCategory])
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
displayName String
passwordHash String? // null tant que le compte n'est pas activé (R1)
roleId String @db.Uuid
role Role @relation(fields: [roleId], references: [id])
isActive Boolean @default(true)
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([roleId])
}

168
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* Seed R0 — idempotent (upserts) : 7 rôles, matrice COMPLÈTE (une ligne par
* rôle × catégorie, invariant R0) et 7 comptes de démonstration (ADR-002).
* La matrice ci-dessous est le point de DÉPART pédagogique : elle vit en base
* et sera administrable (R1) — la modifier ici ne change pas une base déjà
* seedée (les lignes existantes ne sont pas écrasées, voir plus bas).
* Usage : pnpm seed (ou prisma db seed)
*/
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
import {
OBJECT_CATEGORIES,
ROLE_NAMES,
type ObjectCategory,
type RoleName,
} from '@siop/shared';
import * as argon2 from 'argon2';
type Grant = Partial<{
view: boolean;
viewOther: boolean;
create: boolean;
edit: boolean;
delete: boolean;
}>;
const FULL: Grant = { view: true, viewOther: true, create: true, edit: true, delete: true };
const READ: Grant = { view: true, viewOther: true };
/** Matrice de départ (rôle → catégorie → droits ; absent = tout à false). */
const MATRIX: Record<RoleName, Partial<Record<ObjectCategory, Grant>>> = {
Administrateur: Object.fromEntries(
OBJECT_CATEGORIES.map((c) => [c, FULL]),
) as Record<ObjectCategory, Grant>,
Gestionnaire: {
WORK_ORDERS: FULL,
REQUESTS: FULL,
ASSETS: FULL,
LOCATIONS: FULL,
METERS: FULL,
PARTS: FULL,
PURCHASE_ORDERS: FULL,
PEOPLE_TEAMS: { view: true, viewOther: true, create: true, edit: true },
ANALYTICS: READ,
SETTINGS: { view: true },
},
Dispatcher: {
WORK_ORDERS: { view: true, viewOther: true, create: true, edit: true },
REQUESTS: { view: true, viewOther: true, create: true, edit: true },
ASSETS: READ,
LOCATIONS: READ,
METERS: READ,
PARTS: { view: true },
PEOPLE_TEAMS: READ,
ANALYTICS: READ,
},
Technicien: {
WORK_ORDERS: { view: true, edit: true }, // ses OT uniquement (viewOther=false)
REQUESTS: { view: true },
ASSETS: READ,
LOCATIONS: READ,
METERS: { view: true, viewOther: true, create: true }, // relevés
PARTS: { view: true },
},
'Technicien limité': {
WORK_ORDERS: { view: true, edit: true }, // ses OT, sans consultation du parc
ASSETS: { view: true },
},
Demandeur: {
REQUESTS: { view: true, create: true }, // ses demandes uniquement
},
'Vue seule': {
WORK_ORDERS: READ,
REQUESTS: READ,
ASSETS: READ,
LOCATIONS: READ,
METERS: READ,
PARTS: READ,
PURCHASE_ORDERS: READ,
PEOPLE_TEAMS: READ,
ANALYTICS: READ,
},
};
/** 7 comptes démo — un par rôle (personas des maquettes 02-design). */
const DEMO_USERS: { email: string; displayName: string; role: RoleName }[] = [
{ email: 'admin@demo.siop.ma', displayName: 'Amina Benali', role: 'Administrateur' },
{ email: 'dispatcher@demo.siop.ma', displayName: 'Salma Radi', role: 'Dispatcher' },
{ email: 'technicien@demo.siop.ma', displayName: 'Ahmed Meskini', role: 'Technicien' },
{ email: 'technicien-limite@demo.siop.ma', displayName: 'Yassine Bouzid', role: 'Technicien limité' },
{ email: 'gestionnaire@demo.siop.ma', displayName: 'Nadia Cherkaoui', role: 'Gestionnaire' },
{ email: 'demandeur@demo.siop.ma', displayName: 'Karim El Fassi', role: 'Demandeur' },
{ email: 'vue-seule@demo.siop.ma', displayName: 'Omar Senhaji', role: 'Vue seule' },
];
export async function seed(prisma: PrismaClient): Promise<void> {
const roleIds = new Map<RoleName, string>();
for (const name of ROLE_NAMES) {
const role = await prisma.role.upsert({
where: { name },
update: {},
create: { name },
});
roleIds.set(name, role.id);
}
// Matrice complète : une ligne par rôle × catégorie. Les lignes existantes
// ne sont PAS écrasées (la base est la source de vérité, pas ce fichier).
for (const name of ROLE_NAMES) {
const roleId = roleIds.get(name)!;
for (const category of OBJECT_CATEGORIES) {
const g = MATRIX[name][category] ?? {};
await prisma.permission.upsert({
where: { roleId_objectCategory: { roleId, objectCategory: category } },
update: {},
create: {
roleId,
objectCategory: category,
canView: g.view ?? false,
canViewOther: g.viewOther ?? false,
canCreate: g.create ?? false,
canEdit: g.edit ?? false,
canDelete: g.delete ?? false,
},
});
}
}
// Mot de passe commun des comptes démo (la connexion classique reste testable).
const passwordHash = await argon2.hash(
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
);
for (const u of DEMO_USERS) {
await prisma.user.upsert({
where: { email: u.email },
update: {},
create: {
email: u.email,
displayName: u.displayName,
passwordHash,
roleId: roleIds.get(u.role)!,
isDemo: true,
},
});
}
}
/* c8 ignore start — wrapper CLI */
if (require.main === module) {
const prisma = new PrismaClient();
seed(prisma)
.then(async () => {
const [roles, permissions, users] = await Promise.all([
prisma.role.count(),
prisma.permission.count(),
prisma.user.count({ where: { isDemo: true } }),
]);
console.log(
`Seed OK — ${roles} rôles, ${permissions} lignes de matrice, ${users} comptes démo.`,
);
})
.catch((e) => {
console.error(e);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
/* c8 ignore stop */

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
@Module({
controllers: [UsersController],
})
export class UsersModule {}

View File

@@ -0,0 +1,124 @@
/**
* E2E auth — nécessite l'infra locale (pnpm infra:up) : base seedée au beforeAll.
* DEMO_MODE est activé AVANT la composition d'AppModule (voir demo-off.e2e-spec
* pour l'état inverse : les routes démo doivent répondre 404).
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Auth (e2e, DEMO_MODE=true)', () => {
let app: INestApplication;
let http: () => request.Agent;
const prisma = new PrismaClient();
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
http = () => request(app.getHttpServer());
});
afterAll(async () => {
await app?.close();
await prisma.$disconnect();
});
it('GET /health est publique', async () => {
const res = await http().get('/health').expect(200);
expect(res.body.services.database).toBe('up');
});
it('lAPI est fermée par défaut (401 sans jeton)', async () => {
await http().get('/users/me').expect(401);
});
it('GET /auth/demo-accounts liste les 7 comptes seedés, sans secret', async () => {
const res = await http().get('/auth/demo-accounts').expect(200);
expect(res.body.accounts).toHaveLength(7);
for (const account of res.body.accounts) {
expect(Object.keys(account).sort()).toEqual([
'displayName',
'id',
'initials',
'roleName',
]);
}
});
it('demo-login émet un jeton utilisable sur /users/me (critère < 3 s)', async () => {
const start = Date.now();
const { body: accounts } = await http().get('/auth/demo-accounts');
const admin = accounts.accounts.find(
(a: { roleName: string }) => a.roleName === 'Administrateur',
);
const login = await http()
.post('/auth/demo-login')
.send({ userId: admin.id })
.expect(200);
const me = await http()
.get('/users/me')
.set('Authorization', `Bearer ${login.body.accessToken}`)
.expect(200);
expect(Date.now() - start).toBeLessThan(3000);
expect(me.body.role.name).toBe('Administrateur');
expect(me.body.isDemo).toBe(true);
// La matrice complète accompagne le profil (une ligne par catégorie)
expect(me.body.permissions).toHaveLength(10);
});
it('demo-login refuse un compte isDemo=false (403)', async () => {
const role = await prisma.role.findUniqueOrThrow({
where: { name: 'Administrateur' },
});
const real = await prisma.user.upsert({
where: { email: 'real@spelev.ma' },
update: {},
create: {
email: 'real@spelev.ma',
displayName: 'Compte Réel',
roleId: role.id,
isDemo: false,
},
});
try {
await http().post('/auth/demo-login').send({ userId: real.id }).expect(403);
} finally {
await prisma.user.delete({ where: { id: real.id } });
}
});
it('demo-login sur un id inconnu → 404', async () => {
await http()
.post('/auth/demo-login')
.send({ userId: '00000000-0000-4000-8000-000000000000' })
.expect(404);
});
it('la connexion classique fonctionne (seed) et rejette un mauvais mot de passe', async () => {
const password = process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026';
const ok = await http()
.post('/auth/login')
.send({ email: 'dispatcher@demo.siop.ma', password })
.expect(200);
expect(ok.body.user.role.name).toBe('Dispatcher');
await http()
.post('/auth/login')
.send({ email: 'dispatcher@demo.siop.ma', password: 'mauvais' })
.expect(401);
});
it('valide les corps de requête via le contrat Zod (400)', async () => {
await http().post('/auth/demo-login').send({ userId: 'pas-un-uuid' }).expect(400);
await http().post('/auth/login').send({ email: 'x' }).expect(400);
});
});

View File

@@ -0,0 +1,47 @@
/**
* ADR-002, test dédié : quand DEMO_MODE n'est pas actif, les routes démo
* N'EXISTENT PAS (404) — le module n'est pas enregistré.
*/
// « false » plutôt que delete : dotenv (importé par config/env.ts) repeuplerait
// une variable supprimée depuis .env, mais n'écrase jamais une valeur existante.
process.env.DEMO_MODE = 'false';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Auth démo (e2e, DEMO_MODE absent)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app?.close();
});
it('GET /auth/demo-accounts → 404', async () => {
await request(app.getHttpServer()).get('/auth/demo-accounts').expect(404);
});
it('POST /auth/demo-login → 404', async () => {
await request(app.getHttpServer())
.post('/auth/demo-login')
.send({ userId: '00000000-0000-4000-8000-000000000000' })
.expect(404);
});
it('la connexion classique, elle, reste disponible', async () => {
// 401 (identifiants) et non 404 : la route existe bien
await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'nobody@spelev.ma', password: 'x' })
.expect(401);
});
});

View File

@@ -0,0 +1,5 @@
import 'dotenv/config';
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET ??= 'test-secret-0123456789abcdef';
process.env.DATABASE_URL ??= 'postgresql://siop:siop@localhost:5432/siop';

View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

18
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "commonjs",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"strictPropertyInitialization": false,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"outDir": "dist",
"incremental": true
},
"include": ["src", "test", "prisma", "scripts"]
}