Commit 022ad1f4 authored by Kourser's avatar Kourser
Browse files

Console d'instance : organisations, plafonds, bascule tracée

Étape 2 du socle multi-organisations. Le root peut enfin créer une organisation —
jusqu'ici elles ne naissaient que du seed. Il règle ses plafonds et sa rétention,
la suspend, la supprime, et y rattache un compte existant.

La décision qui structure le reste : le root n'a AUCUN droit ambiant sur les
données des organisations. `RolesGuard` reste inchangé, donc `INSTANCE_ADMIN` ne
passe pas les routes `TENANT_ADMIN`. Pour voir un exercice, il entre
explicitement : motif obligatoire, 30 minutes, lecture seule sauf demande
d'écriture, et une ligne dans un journal persisté. C'est le patron du « voir
comme » joueur, appliqué un cran plus haut.

Trois détails qui ne sont pas des détails :

- `InstanceAudit` n'a pas de clé étrangère vers `Tenant`, et recopie son nom.
  Sans ça, supprimer une organisation effacerait la preuve de ce qui lui est
  arrivé — exactement la trace qu'un client viendrait réclamer.
- `InstanceGuard` relit les appartenances au lieu de croire le jeton, et refuse
  les sessions déjà basculées. Un root entré chez un client ne peut pas, de cette
  session, continuer à administrer l'instance.
- La sortie de bascule est un POST, que la lecture seule refuserait : elle rejoint
  la liste blanche du garde de session, sur le modèle de `ALLOWED_OUTSIDE_PLAY`
  côté joueur. Sans elle, un administrateur resterait enfermé 30 minutes.

Suspendre coupe l'accès sans attendre l'expiration des jetons (8 h) : le garde de
session consulte l'état de l'organisation à chaque requête, derrière un cache
court invalidé à chaque changement. La fenêtre de 30 s ne joue que si plusieurs
instances de l'API tournent en parallèle — c'est documenté, pas subi.

La rétention devient propre à chaque organisation. La purge parcourt désormais
les organisations une par une : un `deleteMany` global appliquerait la durée de
l'hébergeur à tout le monde. `null` = valeur du déploiement, `0` = refus explicite
de purge — d'où un `??` et non un `||`, qui aurait confondu les deux.

Correction d'outillage : mon détecteur de doublons i18n ignorait les clés non
quotées (`Déconnexion:`) et ne voyait donc que 393 des 567 clés. Corrigé, il a
immédiatement trouvé un doublon que je venais d'introduire (`Création
impossible`), qui aurait cassé le typage. Aucun autre doublon dans le fichier.

Non vérifié : aucune chaîne Node dans cet environnement, donc rien n'a été
compilé ni testé. Contrôles manuels : équilibre des délimiteurs, balises JSX
appariées, classes CSS présentes, clés i18n sans doublon.

Co-Authored-By: default avatarClaude (RCA) <noreply@anthropic.com>
parent 966103fd
Loading
Loading
Loading
Loading
+28 −3
Original line number Diff line number Diff line
@@ -90,6 +90,29 @@ de chat. Les garde-fous :
  d'un exercice doit rester **fictif** : une capture d'écran réelle peut contenir des données
  personnelles.

## Administration de l'instance

Un compte `INSTANCE_ADMIN` administre le **service**, pas les exercices. Il crée, règle,
suspend et supprime des organisations — il n'a **aucun droit ambiant** sur leurs données.

- **Entrer dans une organisation est une action explicite.** Elle exige un **motif**, dure
  **30 minutes**, et est en **lecture seule** sauf demande explicite d'écriture. Chaque entrée
  est consignée (`InstanceAudit`), avec son motif : c'est ce qui permet de répondre à « qui a
  accédé à nos données, quand et pourquoi ». Le patron est celui du « voir comme » joueur.
- **Le garde d'instance ne croit pas le jeton** : il relit les appartenances pour vérifier le
  rôle *et* l'organisation système. Il refuse aussi les sessions déjà basculées — un
  administrateur entré chez un client ne peut pas, depuis cette session, continuer à
  administrer l'instance. La seule écriture qui lui reste est d'en sortir.
- **Le journal survit à la suppression.** `InstanceAudit` n'a volontairement aucune clé
  étrangère vers `Tenant` : supprimer une organisation effacerait sinon la preuve de ce qui
  lui est arrivé. Le nom est recopié, pas référencé.
- **La suppression demande le slug** en confirmation : la cascade efface tous les exercices,
  messages, documents et participants de l'organisation.
- **Suspendre coupe l'accès sans attendre l'expiration des jetons.** Le garde de session
  consulte l'état de l'organisation à chaque requête, via un cache court invalidé à chaque
  changement. Prise d'effet immédiate sur une instance unique ; jusqu'à 30 secondes si
  plusieurs instances de l'API tournent en parallèle, chacune avec son cache.

## Sessions en observation

Le garde de session (`apps/api/src/auth/auth.guard.ts`) refuse toute méthode autre que
@@ -138,9 +161,11 @@ lui appartient ; elle n'est pas prise par défaut à sa place.

- **Minimisation** : les joueurs n'ont pas de compte ; les scénarios doivent utiliser des
  **données fictives** (ne jamais y verser de vraies données personnelles).
- **Rétention** : purge automatique des exercices archivés au-delà de `RETENTION_DAYS` jours
  (désactivée par défaut ; à configurer selon votre politique). Purge manuelle possible par un
  administrateur d'organisation.
- **Rétention** : purge automatique des exercices archivés au-delà du délai applicable.
  Chaque organisation peut fixer le sien (`retentionDays`) — en SaaS, la durée de conservation
  relève de la politique du client, pas de celle de l'hébergeur. Sans valeur propre, celle du
  déploiement (`RETENTION_DAYS`) s'applique ; une valeur de `0` refuse explicitement toute
  purge automatique. Purge manuelle possible par un administrateur d'organisation.
- **Droit à l'effacement** : la suppression d'un exercice ou d'un tenant supprime en cascade
  toutes les données associées (messages, journaux, documents, participants…), y compris les
  vérifications d'accès et les réponses au questionnaire de RETEX.
+34 −0
Original line number Diff line number Diff line
-- Console d'instance : etat, plafonds et retention par organisation, plus un
-- journal d'audit persiste des actions d'administration du service.

-- CreateEnum
CREATE TYPE "TenantStatus" AS ENUM ('ACTIVE', 'SUSPENDED');

-- AlterTable
ALTER TABLE "Tenant" ADD COLUMN "status" "TenantStatus" NOT NULL DEFAULT 'ACTIVE';
ALTER TABLE "Tenant" ADD COLUMN "maxExercises" INTEGER;
ALTER TABLE "Tenant" ADD COLUMN "maxUsers" INTEGER;
ALTER TABLE "Tenant" ADD COLUMN "maxStorageMb" INTEGER;
-- null = valeur du deploiement (RETENTION_DAYS) : aucune organisation existante
-- ne change de comportement.
ALTER TABLE "Tenant" ADD COLUMN "retentionDays" INTEGER;

-- CreateTable
-- Aucune cle etrangere vers "Tenant" : la trace doit survivre a la suppression de
-- l'organisation qu'elle concerne. Le nom est recopie, pas reference.
CREATE TABLE "InstanceAudit" (
    "id" TEXT NOT NULL,
    "actorUserId" TEXT NOT NULL,
    "actorEmail" TEXT NOT NULL,
    "action" TEXT NOT NULL,
    "tenantId" TEXT,
    "tenantName" TEXT,
    "reason" TEXT,
    "details" JSONB,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT "InstanceAudit_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "InstanceAudit_createdAt_idx" ON "InstanceAudit"("createdAt");
CREATE INDEX "InstanceAudit_tenantId_idx" ON "InstanceAudit"("tenantId");
+43 −0
Original line number Diff line number Diff line
@@ -14,14 +14,34 @@ datasource db {
}

/// Une organisation cliente. Porte le cloisonnement multi-tenant (cf. spec-technique §1.2).
/// Etat d'une organisation. Suspendue, elle refuse toute session : ses donnees
/// restent intactes mais plus personne n'y travaille.
enum TenantStatus {
  ACTIVE
  SUSPENDED
}

model Tenant {
  id           String           @id @default(cuid())
  name         String
  slug         String           @unique
  status       TenantStatus     @default(ACTIVE)
  /// Organisation systeme : elle heberge les comptes d'administration de
  /// l'instance et n'est jamais listee comme une organisation cliente.
  /// Une seule existe, creee par le seed.
  isSystem     Boolean          @default(false)

  /// Plafonds d'usage. `null` = pas de plafond. Poses des maintenant meme si tout
  /// n'est pas encore applique : les ajouter plus tard sur des organisations
  /// vivantes couterait une migration, alors qu'ils ne coutent rien ici.
  maxExercises  Int?
  maxUsers      Int?
  maxStorageMb  Int?

  /// Duree de conservation propre a l'organisation, en jours. `null` = valeur du
  /// deploiement (RETENTION_DAYS). En SaaS, la retention releve de la politique
  /// de chaque client, pas de celle de l'hebergeur.
  retentionDays Int?
  /// Logo de l'organisation (cle de fichier televerse). Avatar par defaut du
  /// compte officiel sur les reseaux simules ; un exercice peut le remplacer.
  logoPath     String?
@@ -102,6 +122,29 @@ model Membership {
  @@index([userId])
}

/// Journal d'audit des actions d'ADMINISTRATION DE L'INSTANCE.
///
/// Volontairement HORS cloisonnement, et volontairement sans relation vers
/// `Tenant` : la trace doit survivre a la suppression de l'organisation qu'elle
/// concerne, sinon supprimer une organisation effacerait la preuve de ce qui lui
/// est arrive. Le nom est donc recopie, pas reference.
model InstanceAudit {
  id          String   @id @default(cuid())
  actorUserId String
  actorEmail  String
  action      String
  tenantId    String?
  tenantName  String?
  /// Motif saisi par l'administrateur, pour les actions qui en demandent un
  /// (entree dans une organisation, suspension).
  reason      String?
  details     Json?
  createdAt   DateTime @default(now())

  @@index([createdAt])
  @@index([tenantId])
}

/// Etat d'un exercice, du brouillon a l'archivage.
enum ExerciseStatus {
  DRAFT
+4 −0
Original line number Diff line number Diff line
@@ -5,6 +5,8 @@ import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { PrismaModule } from './prisma/prisma.module';
import { AuditModule } from './common/audit.module';
import { TenantStatusModule } from './common/tenant-status.module';
import { InstanceModule } from './instance/instance.module';
import { HealthModule } from './health/health.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
@@ -35,9 +37,11 @@ function staticWebModules(): DynamicModule[] {
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    AuditModule,
    TenantStatusModule,
    HealthModule,
    AuthModule,
    UsersModule,
    InstanceModule,
    ExercisesModule,
    EngineModule,
    MaintenanceModule,
+24 −2
Original line number Diff line number Diff line
@@ -7,14 +7,26 @@ import {
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import { TenantStatusService } from '../common/tenant-status.service';
import { SESSION_COOKIE, type AuthUser, type JwtPayload } from './auth.types';

const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);

/**
 * Ecritures tolerees en session d'observation. Une seule : SORTIR de l'observation.
 * Sans cette exception, un administrateur d'instance entre en lecture seule
 * resterait enferme jusqu'a l'expiration de son jeton. Meme motif que la liste
 * blanche du garde joueur.
 */
const ALLOWED_IN_READ_ONLY = ['/instance/exit'];

/** Exige une session valide et attache l'utilisateur (`req.user`). */
@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly jwt: JwtService) {}
  constructor(
    private readonly jwt: JwtService,
    private readonly tenants: TenantStatusService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest<Request & { user?: AuthUser }>();
@@ -28,6 +40,12 @@ export class AuthGuard implements CanActivate {
      throw new UnauthorizedException('Session invalide ou expiree');
    }

    // Organisation suspendue (ou disparue) : la session cesse d'etre servie, sans
    // attendre l'expiration du jeton. Lecture mise en cache, cf. TenantStatusService.
    if (await this.tenants.isSuspended(payload.tid)) {
      throw new ForbiddenException('Organisation suspendue');
    }

    req.user = {
      userId: payload.sub,
      tenantId: payload.tid,
@@ -39,7 +57,11 @@ export class AuthGuard implements CanActivate {
    // Point de controle UNIQUE de la lecture seule, sur le modele du garde joueur :
    // une bascule d'administrateur d'instance en observation ne doit rien pouvoir
    // ecrire, et cette regle doit couvrir aussi les routes ajoutees plus tard.
    if (payload.ro && !READ_METHODS.has(req.method)) {
    if (
      payload.ro &&
      !READ_METHODS.has(req.method) &&
      !ALLOWED_IN_READ_ONLY.some((suffix) => req.path.endsWith(suffix))
    ) {
      throw new ForbiddenException('Session en observation : aucune écriture possible');
    }

Loading