Commit 966103fd authored by Kourser's avatar Kourser
Browse files

Appartenances : le rôle quitte le compte, et l'instance a un administrateur

Étape 1 du socle multi-organisations. Un compte devient une identité GLOBALE ;
c'est `Membership` qui le rattache à une organisation et qui porte son rôle. Une
consultante peut désormais animer chez un client et concevoir chez un autre avec
une seule identité — impossible avant, l'email étant unique et le tenant porté
par le compte.

La refonte reste contenue parce que `AuthUser.tenantId` continue de désigner
l'organisation ACTIVE : les 106 sites qui lisent `me.tenantId` ne bougent pas
d'une ligne. Seuls l'authentification, la signature du jeton et l'administration
des membres changent.

Sécurité — trois pièges traités, dont un que la refonte ouvrait :

- `INSTANCE_ADMIN` entre dans l'enum des rôles. Tel quel, un admin d'organisation
  pouvait se l'attribuer via `POST /users` et prendre la main sur l'instance.
  Deux verrous : les routes d'organisation n'acceptent que `TENANT_ROLES`, et
  `isInstanceAdmin` exige le rôle ET l'organisation système (module pur, testé).
- Réinitialiser le mot de passe de quelqu'un présent dans plusieurs organisations
  donnerait à un admin d'organisation la main sur ses accès ailleurs. Refusé,
  comme le changement d'email. Le rôle reste modifiable : sa portée est locale.
- Retirer un membre supprime son APPARTENANCE. Le compte ne disparaît que s'il ne
  sert plus nulle part.

Le garde de session applique la lecture seule en un point unique, sur le modèle
du garde joueur : un jeton marqué `ro` refuse toute méthode autre que GET/HEAD.
C'est le socle de la bascule d'administrateur d'instance (étape 2).

Un compte existant ne peut pas être rattaché à une organisation depuis la console
d'organisation : ce serait y ajouter quelqu'un sans son accord. Le rattachement
reste une action d'instance jusqu'à ce que les invitations existent (étape 4).

La migration est conservatrice : chaque compte existant devient une appartenance
unique, avec exactement l'organisation et le rôle qu'il avait. `ALTER TYPE ...
ADD VALUE` tient dans la transaction (PostgreSQL 16, et la valeur ajoutée n'est
pas utilisée par le report).

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, absence de
doublon de clé i18n (393 clés), aucune référence orpheline à `User.tenantId` ou
`User.role`, `User` retiré des modèles cloisonnés au profit de `Membership`.

Co-Authored-By: default avatarClaude (RCA) <noreply@anthropic.com>
parent f8e6378a
Loading
Loading
Loading
Loading
+27 −0
Original line number Diff line number Diff line
@@ -16,6 +16,26 @@ entités métier et **injecté automatiquement** dans chaque requête via une ex
(`apps/api/src/prisma/tenant-scope.ts`), pour qu'aucun code ne puisse lire ou écrire les données
d'un autre tenant. Cet invariant est couvert par des tests unitaires.

## Appartenances et portées d'autorité

Un compte est une **identité globale** ; c'est le lien `Membership` qui le rattache à une
organisation et qui porte son rôle. Une même personne peut donc animer chez un client et
concevoir chez un autre avec une seule identité — et un seul mot de passe à protéger.

Trois conséquences de sécurité, toutes appliquées côté serveur :

- **`INSTANCE_ADMIN` ne vaut que dans l'organisation système.** Le rôle et l'organisation
  sont vérifiés ensemble (`apps/api/src/auth/roles.ts`, couvert par des tests). Sans cette
  double condition, un administrateur d'organisation pourrait créer chez lui un compte
  `INSTANCE_ADMIN` et prendre la main sur toute l'instance. Le rôle est par ailleurs exclu
  des valeurs acceptées par les routes d'organisation (`TENANT_ROLES`).
- **Un admin d'organisation n'a pas la main sur une identité partagée.** Réinitialiser le
  mot de passe ou changer l'email de quelqu'un qui appartient à plusieurs organisations est
  refusé : ce serait prendre le contrôle de ses accès ailleurs. Le rôle, lui, reste
  modifiable — il n'a de portée que dans l'organisation concernée.
- **Retirer un membre supprime son appartenance, pas son identité.** Le compte n'est effacé
  que s'il ne restait aucune autre appartenance.

## Authentification

- Animateurs / concepteurs / observateurs : email + mot de passe (hachés avec bcrypt),
@@ -70,6 +90,13 @@ de chat. Les garde-fous :
  d'un exercice doit rester **fictif** : une capture d'écran réelle peut contenir des données
  personnelles.

## Sessions en observation

Le garde de session (`apps/api/src/auth/auth.guard.ts`) refuse toute méthode autre que
`GET`/`HEAD` quand le jeton porte la marque de lecture seule. C'est un point de contrôle
**unique**, comme pour les sessions joueur : il couvre les routes existantes et celles qui
seront ajoutées ensuite, sans qu'aucune n'ait à s'en préoccuper.

## Observation d'un espace joueur (« voir comme »)

Un animateur peut ouvrir l'espace d'un participant pour y voir ce que ce dernier voit.
+48 −0
Original line number Diff line number Diff line
-- Le role quitte le compte pour rejoindre le lien d'appartenance.
--
-- Avant : un compte appartenait a une organisation et y tenait un role. Une meme
-- personne ne pouvait donc pas intervenir chez deux clients. Apres : `User` est une
-- identite globale, `Membership` porte l'organisation ET le role.
--
-- La migration est CONSERVATRICE : chaque compte existant devient une appartenance
-- unique, avec exactement l'organisation et le role qu'il avait.

-- ALTER TYPE ... ADD VALUE tient dans une transaction depuis PostgreSQL 12 tant que
-- la valeur ajoutee n'est pas utilisee dans la meme transaction. Le report ci-dessous
-- ne reprend que des valeurs preexistantes : la condition est respectee.
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'INSTANCE_ADMIN' BEFORE 'TENANT_ADMIN';

-- Organisation systeme : elle heberge les comptes d'administration de l'instance et
-- n'est jamais listee comme une organisation cliente.
ALTER TABLE "Tenant" ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false;

-- CreateTable
CREATE TABLE "Membership" (
    "id" TEXT NOT NULL,
    "userId" TEXT NOT NULL,
    "tenantId" TEXT NOT NULL,
    "role" "UserRole" NOT NULL DEFAULT 'ANIMATOR',
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,

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

CREATE UNIQUE INDEX "Membership_userId_tenantId_key" ON "Membership"("userId", "tenantId");
CREATE INDEX "Membership_tenantId_idx" ON "Membership"("tenantId");
CREATE INDEX "Membership_userId_idx" ON "Membership"("userId");

ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- Report des donnees : une appartenance par compte existant, a l'identique.
-- gen_random_uuid() est disponible en natif depuis PostgreSQL 13 (pgcrypto non requis).
INSERT INTO "Membership" ("id", "userId", "tenantId", "role", "createdAt", "updatedAt")
SELECT gen_random_uuid()::text, "id", "tenantId", "role", "createdAt", "updatedAt"
FROM "User";

-- Le compte ne porte plus ni organisation ni role : ils vivent dans l'appartenance.
DROP INDEX IF EXISTS "User_tenantId_idx";
ALTER TABLE "User" DROP CONSTRAINT IF EXISTS "User_tenantId_fkey";
ALTER TABLE "User" DROP COLUMN "tenantId";
ALTER TABLE "User" DROP COLUMN "role";
+32 −4
Original line number Diff line number Diff line
@@ -18,10 +18,14 @@ model Tenant {
  id           String           @id @default(cuid())
  name         String
  slug         String           @unique
  /// 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)
  /// 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?
  users        User[]
  memberships  Membership[]
  exercises    Exercise[]
  characters   Character[]
  participants Participant[]
@@ -51,7 +55,12 @@ model Tenant {
}

/// Roles applicatifs (RBAC). Les joueurs ne sont PAS des User (acces par lien/jeton, lot 2).
/// Role d'un compte DANS une organisation (porte par Membership, pas par User :
/// une meme personne peut etre animatrice ici et conceptrice ailleurs).
/// INSTANCE_ADMIN fait exception : il ne vaut que dans l'organisation systeme et
/// designe l'administration du service lui-meme.
enum UserRole {
  INSTANCE_ADMIN
  TENANT_ADMIN
  DESIGNER
  ANIMATOR
@@ -61,17 +70,36 @@ enum UserRole {
/// Utilisateur (animateur / concepteur / observateur / admin d'organisation).
model User {
  id           String    @id @default(cuid())
  tenantId     String
  tenant       Tenant    @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  email        String    @unique
  passwordHash String
  displayName  String
  role         UserRole  @default(ANIMATOR)
  lastLoginAt  DateTime?

  /// Organisations auxquelles ce compte appartient, avec son role dans chacune.
  memberships  Membership[]

  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt
}

/// Appartenance d'un compte a une organisation, et role qu'il y tient.
///
/// C'est ce lien — et non le compte — qui porte le role : une consultante peut
/// animer chez un client et concevoir chez un autre avec une seule identite.
model Membership {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  tenantId  String
  tenant    Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  role      UserRole @default(ANIMATOR)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@unique([userId, tenantId])
  @@index([tenantId])
  @@index([userId])
}

/// Etat d'un exercice, du brouillon a l'archivage.
+47 −19
Original line number Diff line number Diff line
@@ -3,35 +3,44 @@ import { hash } from 'bcryptjs';

const prisma = new PrismaClient();

const DEFAULT_PASSWORD = 'ChangeMoi123!';

/**
 * Seed de demonstration (lots 0-1) : deux organisations cloisonnees,
 * chacune avec un administrateur. Idempotent (rejouable sans doublon).
 * Cree (ou complete) une organisation et l'un de ses membres. Idempotent.
 *
 * Le role vit sur l'APPARTENANCE, pas sur le compte : la meme personne peut donc
 * etre rattachee ici a plusieurs organisations avec un role different dans chacune.
 */
async function ensureTenantWithAdmin(
async function ensureMember(
  slug: string,
  name: string,
  adminEmail: string,
  adminName: string,
  email: string,
  displayName: string,
  role: 'INSTANCE_ADMIN' | 'TENANT_ADMIN' | 'DESIGNER' | 'ANIMATOR' | 'OBSERVER',
  isSystem = false,
): Promise<void> {
  const tenant = await prisma.tenant.upsert({
    where: { slug },
    update: { name },
    create: { name, slug },
    update: { name, isSystem },
    create: { name, slug, isSystem },
  });

  const passwordHash = await hash('ChangeMoi123!', 12);
  await prisma.user.upsert({
    where: { email: adminEmail },
  const user = await prisma.user.upsert({
    where: { email },
    update: {},
    create: {
      tenantId: tenant.id,
      email: adminEmail,
      displayName: adminName,
      passwordHash,
      role: 'TENANT_ADMIN',
      email,
      displayName,
      passwordHash: await hash(DEFAULT_PASSWORD, 12),
    },
  });
  console.log(`  ${name} (${slug}) — admin: ${adminEmail}`);

  await prisma.membership.upsert({
    where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
    update: { role },
    create: { userId: user.id, tenantId: tenant.id, role },
  });
  console.log(`  ${name} (${slug}) — ${role.toLowerCase()}: ${email}`);
}

async function ensureDemoExercise(tenantSlug: string): Promise<void> {
@@ -64,11 +73,30 @@ async function ensureDemoExercise(tenantSlug: string): Promise<void> {
}

async function main(): Promise<void> {
  // Organisation systeme : elle n'heberge aucun exercice et n'apparait dans
  // aucune liste d'organisations. Elle existe pour porter le compte qui
  // administre l'instance elle-meme.
  console.log("Organisation systeme et compte d'administration de l'instance :");
  await ensureMember(
    'instance',
    "Administration de l'instance",
    'root@cythin.local',
    'Administrateur',
    'INSTANCE_ADMIN',
    true,
  );

  console.log('Seed des organisations de demonstration :');
  await ensureTenantWithAdmin('meridien', 'Groupe Meridien', 'admin@meridien.exercice', 'Admin Meridien');
  await ensureTenantWithAdmin('helios', 'Cooperative Helios', 'admin@helios.exercice', 'Admin Helios');
  await ensureMember('meridien', 'Groupe Meridien', 'admin@meridien.exercice', 'Admin Meridien', 'TENANT_ADMIN');
  await ensureMember('helios', 'Cooperative Helios', 'admin@helios.exercice', 'Admin Helios', 'TENANT_ADMIN');

  // Demonstration du multi-appartenance : une consultante intervient chez les deux,
  // avec un role different dans chacune. Impossible avant la bascule.
  await ensureMember('meridien', 'Groupe Meridien', 'consultante@cythin.local', 'Camille Doriot', 'ANIMATOR');
  await ensureMember('helios', 'Cooperative Helios', 'consultante@cythin.local', 'Camille Doriot', 'DESIGNER');

  await ensureDemoExercise('meridien');
  console.log('Mot de passe par defaut : ChangeMoi123!  (a changer)');
  console.log(`Mot de passe par defaut : ${DEFAULT_PASSWORD}  (a changer)`);
}

main()
+29 −4
Original line number Diff line number Diff line
@@ -14,7 +14,8 @@ import { AuthGuard } from './auth.guard';
import { CurrentUser } from './current-user.decorator';
import { LoginDto } from './dto/login.dto';
import { SESSION_COOKIE, type AuthUser } from './auth.types';
import type { PublicUser } from './public-user';
import type { SessionUser } from './public-user';
import { SelectOrganisationDto } from './dto/select-organisation.dto';

const EIGHT_HOURS_MS = 8 * 60 * 60 * 1000;

@@ -37,12 +38,33 @@ export class AuthController {
  async login(
    @Body() dto: LoginDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise<PublicUser> {
  ): Promise<SessionUser> {
    const { token, user } = await this.auth.login(dto.email, dto.password);
    res.cookie(SESSION_COOKIE, token, cookieOptions());
    return user;
  }

  /**
   * Change l'organisation active. Un compte peut appartenir a plusieurs
   * organisations ; c'est ici qu'on choisit celle dans laquelle on travaille.
   */
  @Post('organisation')
  @UseGuards(AuthGuard)
  @HttpCode(HttpStatus.OK)
  async selectOrganisation(
    @CurrentUser() me: AuthUser,
    @Body() dto: SelectOrganisationDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise<SessionUser> {
    const { token, user } = await this.auth.selectOrganisation(
      me.userId,
      dto.tenantId,
      me.switchedBy,
    );
    res.cookie(SESSION_COOKIE, token, cookieOptions());
    return user;
  }

  @Post('logout')
  @HttpCode(HttpStatus.NO_CONTENT)
  logout(@Res({ passthrough: true }) res: Response): void {
@@ -51,7 +73,10 @@ export class AuthController {

  @Get('me')
  @UseGuards(AuthGuard)
  me(@CurrentUser() user: AuthUser): Promise<PublicUser> {
    return this.auth.me(user.tenantId, user.userId);
  me(@CurrentUser() user: AuthUser): Promise<SessionUser> {
    return this.auth.me(user.userId, user.tenantId, user.role, {
      switchedBy: user.switchedBy,
      readOnly: user.readOnly,
    });
  }
}
Loading