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:
@@ -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;
|
||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
47
apps/api/prisma/schema.prisma
Normal file
47
apps/api/prisma/schema.prisma
Normal 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
168
apps/api/prisma/seed.ts
Normal 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 */
|
||||
Reference in New Issue
Block a user