From 4a6406170690717e3568090d324c72ef88ed447f Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 1 Jun 2026 07:46:29 -0400 Subject: [PATCH] Preparer les schemas SQL par groupe --- .../templates/groupe-meditation-backup.j2 | 5 + backend/app/main.py | 7 + backend/app/models/groupe.py | 1 + backend/app/routers/admin.py | 22 +++ backend/app/services/admin_database.py | 3 + backend/app/services/tenant_schemas.py | 153 ++++++++++++++++++ deploy/tenant_schemas.py | 24 +++ docs/93_MULTI_GROUPE.md | 12 +- 8 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/tenant_schemas.py create mode 100644 deploy/tenant_schemas.py diff --git a/ansible/roles/backup/templates/groupe-meditation-backup.j2 b/ansible/roles/backup/templates/groupe-meditation-backup.j2 index e8046c8..c7fbe81 100644 --- a/ansible/roles/backup/templates/groupe-meditation-backup.j2 +++ b/ansible/roles/backup/templates/groupe-meditation-backup.j2 @@ -13,10 +13,15 @@ mkdir -p "$BACKUP_DIR" export PGPASSWORD="{{ groupe_meditation_db_password }}" cd "{{ groupe_meditation_backend_dir }}" +"{{ groupe_meditation_backend_dir }}/venv/bin/python" "{{ groupe_meditation_deploy_dir }}/tenant_schemas.py" "{{ groupe_meditation_backend_dir }}/venv/bin/python" "{{ groupe_meditation_deploy_dir }}/group_backup.py" all pg_dump -U "$DB_USER" -h "$DB_HOST" -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump" +for schema in $(PGPASSWORD="$PGPASSWORD" psql -U "$DB_USER" -h "$DB_HOST" -d "$DB_NAME" -Atc "SELECT tenant_schema FROM groupes WHERE tenant_schema IS NOT NULL ORDER BY tenant_schema"); do + pg_dump -U "$DB_USER" -h "$DB_HOST" -n "$schema" -Fc "$DB_NAME" > "$BACKUP_DIR/${schema}_${DATE}.dump" +done find "$BACKUP_DIR" -name "grpmed_*.dump" -mtime +"$RETENTION_DAYS" -delete find "$BACKUP_DIR" -name "group_*.json" -mtime +"$RETENTION_DAYS" -delete +find "$BACKUP_DIR" -name "grp_*.dump" -mtime +"$RETENTION_DAYS" -delete echo "[$(date)] Sauvegarde terminee: grpmed_${DATE}.dump" diff --git a/backend/app/main.py b/backend/app/main.py index 1e96fc1..c3d2388 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from sqlalchemy import text from app.core.config import settings from app.core.audit import AuditMiddleware from app.core.database import engine, Base +from app.services.tenant_schemas import ensure_all_tenant_schemas from app.routers import admin, auth, membres, postes, accueil, invitations, collectes, reunions, depenses, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, propositions, operations_bancaires, evenements, notifications, journal # Import models pour que create_all crée toutes les tables @@ -36,6 +37,11 @@ from app.models.instance_admin import InstanceAdmin # noqa: F401 async def lifespan(app: FastAPI): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await conn.execute(text("ALTER TABLE groupes ADD COLUMN IF NOT EXISTS tenant_schema VARCHAR(80) UNIQUE")) + await conn.execute(text( + "UPDATE groupes SET tenant_schema = 'grp_' || replace(id::text, '-', '') " + "WHERE tenant_schema IS NULL" + )) await conn.execute(text( "ALTER TABLE groupes " "ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'" @@ -118,6 +124,7 @@ async def lifespan(app: FastAPI): "ventes_litterature", ): await conn.execute(text(f"CREATE INDEX IF NOT EXISTS idx_{table}_groupe_id ON {table} (groupe_id)")) + await ensure_all_tenant_schemas(conn) yield await engine.dispose() diff --git a/backend/app/models/groupe.py b/backend/app/models/groupe.py index f5b67e6..95575df 100644 --- a/backend/app/models/groupe.py +++ b/backend/app/models/groupe.py @@ -11,6 +11,7 @@ class Groupe(Base): id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) nom: Mapped[str] = mapped_column(String(100), nullable=False) + tenant_schema: Mapped[str | None] = mapped_column(String(80), unique=True) district: Mapped[str] = mapped_column(String(20), default="87-16") region: Mapped[str] = mapped_column(String(20), default="87") adresse_local: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 4981a0e..a665904 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -20,6 +20,7 @@ from app.services.group_backups import ( lister_sauvegardes_groupe, restaurer_sauvegarde_groupe, ) +from app.services.tenant_schemas import refresh_all_tenant_schemas, refresh_group_tenant_schema from app.models.groupe import Groupe from app.models.membre import Membre from app.models.reunion import Reunion @@ -114,6 +115,26 @@ async def backup_groupe_admin( raise HTTPException(status_code=400, detail=str(exc)) +@router.post("/groupes/{groupe_id}/tenant-schema/refresh") +async def refresh_tenant_schema_groupe_admin( + groupe_id: str, + _sysadmin=Depends(require_sysadmin), + db: AsyncSession = Depends(get_db), +): + try: + return await refresh_group_tenant_schema(db, groupe_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.post("/tenant-schemas/refresh") +async def refresh_tenant_schemas_admin( + _sysadmin=Depends(require_sysadmin), + db: AsyncSession = Depends(get_db), +): + return await refresh_all_tenant_schemas(db) + + @router.get("/groupes/{groupe_id}/backups") async def backups_groupe_admin( groupe_id: str, @@ -164,6 +185,7 @@ async def liste_groupes_admin( "assemblee_affaires_ordre": groupe.assemblee_affaires_ordre, "format_reunion": groupe.format_reunion, "lien_aa87": groupe.lien_aa87, + "tenant_schema": groupe.tenant_schema, "cree_le": groupe.cree_le, "membres": nb_membres, "reunions": nb_reunions, diff --git a/backend/app/services/admin_database.py b/backend/app/services/admin_database.py index 306c0fc..b749c50 100644 --- a/backend/app/services/admin_database.py +++ b/backend/app/services/admin_database.py @@ -40,6 +40,7 @@ from app.models.rotation import Rotation from app.models.transaction_comptable import TransactionComptable from app.models.vente_litterature import VenteLitterature from app.models.vente_jeton import VenteJeton +from app.services.tenant_schemas import ensure_group_tenant_schema DEMO_GROUP_NAME = "Groupe Démonstration" OLD_DEMO_GROUP_NAMES = ("Groupe Résilience",) @@ -228,6 +229,7 @@ async def creer_groupe(db: AsyncSession, data: dict) -> Groupe: ) db.add(groupe) await db.flush() + await ensure_group_tenant_schema(db, groupe) await _peupler_groupe_base(db, groupe) await db.flush() return groupe @@ -286,6 +288,7 @@ async def _creer_base( ) db.add(groupe) await db.flush() + await ensure_group_tenant_schema(db, groupe) sysadmin, postes, litterature = await _peupler_groupe_base(db, groupe) return groupe, sysadmin, postes, litterature diff --git a/backend/app/services/tenant_schemas.py b/backend/app/services/tenant_schemas.py new file mode 100644 index 0000000..6fa8ad7 --- /dev/null +++ b/backend/app/services/tenant_schemas.py @@ -0,0 +1,153 @@ +"""Préparation des schémas PostgreSQL par groupe. + +Cette couche pose l'isolation physique sans encore changer le chemin de lecture +principal de l'application. Les schémas tenant sont remplis depuis les tables +publiques et servent de base de migration vers l'exécution par search_path. +""" +from __future__ import annotations + +import re +import uuid + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession + +TENANT_TABLES = [ + "groupes", + "membres", + "postes", + "postes_modules", + "config_repartition", + "destinataires_contributions", + "litterature", + "reserves", + "reunions", + "presences", + "pv_reunions", + "rapports_rsg", + "candidatures", + "propositions", + "affectations", + "rotations", + "collectes", + "depenses", + "envois_contributions", + "evenements", + "invitations", + "jetons", + "ventes_jetons", + "ventes_litterature", + "operations_bancaires", + "mouvements_reserves", + "transactions_comptables", + "rapports_adoptes", + "push_subscriptions", + "sync_log", + "historique_modifications", + "journal_actions", +] + +CHILD_TABLE_FILTERS = { + "postes_modules": "poste_id IN (SELECT id FROM public.postes WHERE groupe_id = :gid)", + "presences": "reunion_id IN (SELECT id FROM public.reunions WHERE groupe_id = :gid)", + "pv_reunions": "reunion_id IN (SELECT id FROM public.reunions WHERE groupe_id = :gid)", +} + + +def tenant_schema_name(groupe_id) -> str: + valeur = str(groupe_id) + try: + valeur = uuid.UUID(valeur).hex + except ValueError: + valeur = re.sub(r"[^a-zA-Z0-9_]", "_", valeur).lower() + return f"grp_{valeur}" + + +def _quote_ident(name: str) -> str: + if not re.fullmatch(r"[a-z_][a-z0-9_]*", name): + raise ValueError("Nom de schéma invalide") + return f'"{name}"' + + +async def ensure_tenant_schema_column(conn: AsyncConnection) -> None: + await conn.execute(text("ALTER TABLE groupes ADD COLUMN IF NOT EXISTS tenant_schema VARCHAR(80) UNIQUE")) + await conn.execute(text( + "UPDATE groupes SET tenant_schema = 'grp_' || replace(id::text, '-', '') " + "WHERE tenant_schema IS NULL" + )) + + +async def ensure_tenant_schema(conn: AsyncConnection, schema: str) -> None: + schema_ident = _quote_ident(schema) + await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_ident}")) + for table in TENANT_TABLES: + await conn.execute(text( + f"CREATE TABLE IF NOT EXISTS {schema_ident}.{_quote_ident(table)} " + f"(LIKE public.{_quote_ident(table)} INCLUDING ALL)" + )) + + +async def recreate_tenant_tables(conn: AsyncConnection, schema: str) -> None: + schema_ident = _quote_ident(schema) + await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_ident}")) + for table in reversed(TENANT_TABLES): + await conn.execute(text(f"DROP TABLE IF EXISTS {schema_ident}.{_quote_ident(table)} CASCADE")) + for table in TENANT_TABLES: + await conn.execute(text( + f"CREATE TABLE {schema_ident}.{_quote_ident(table)} " + f"(LIKE public.{_quote_ident(table)} INCLUDING ALL)" + )) + + +async def ensure_all_tenant_schemas(conn: AsyncConnection) -> None: + await ensure_tenant_schema_column(conn) + result = await conn.execute(text("SELECT tenant_schema FROM groupes WHERE tenant_schema IS NOT NULL")) + for schema in result.scalars().all(): + await ensure_tenant_schema(conn, schema) + + +async def ensure_group_tenant_schema(db: AsyncSession, groupe) -> str: + schema = groupe.tenant_schema or tenant_schema_name(groupe.id) + groupe.tenant_schema = schema + await db.flush() + conn = await db.connection() + await ensure_tenant_schema(conn, schema) + return schema + + +async def refresh_group_tenant_schema(db: AsyncSession, groupe_id) -> dict: + conn = await db.connection() + await ensure_tenant_schema_column(conn) + result = await conn.execute( + text("SELECT tenant_schema FROM groupes WHERE id = :gid"), + {"gid": str(groupe_id)}, + ) + schema = result.scalar_one_or_none() + if not schema: + schema = tenant_schema_name(groupe_id) + await conn.execute( + text("UPDATE groupes SET tenant_schema = :schema WHERE id = :gid"), + {"schema": schema, "gid": str(groupe_id)}, + ) + await recreate_tenant_tables(conn, schema) + schema_ident = _quote_ident(schema) + + params = {"gid": str(groupe_id)} + for table in TENANT_TABLES: + table_ident = _quote_ident(table) + if table == "groupes": + where_clause = "id = :gid" + elif table in CHILD_TABLE_FILTERS: + where_clause = CHILD_TABLE_FILTERS[table] + else: + where_clause = "groupe_id = :gid" + await conn.execute(text( + f"INSERT INTO {schema_ident}.{table_ident} " + f"SELECT * FROM public.{table_ident} WHERE {where_clause}" + ), params) + return {"schema": schema, "tables": len(TENANT_TABLES)} + + +async def refresh_all_tenant_schemas(db: AsyncSession) -> list[dict]: + result = await db.execute(text("SELECT id FROM groupes ORDER BY nom")) + return [await refresh_group_tenant_schema(db, groupe_id) for groupe_id in result.scalars().all()] diff --git a/deploy/tenant_schemas.py b/deploy/tenant_schemas.py new file mode 100644 index 0000000..e2e6651 --- /dev/null +++ b/deploy/tenant_schemas.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Prépare et rafraîchit les schémas PostgreSQL par groupe.""" +import asyncio +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +from app.core.database import async_session, engine # noqa: E402 +from app.services.tenant_schemas import refresh_all_tenant_schemas # noqa: E402 + + +async def main(): + async with async_session() as db: + results = await refresh_all_tenant_schemas(db) + await db.commit() + for result in results: + print(f"{result['schema']} ({result['tables']} tables)") + await engine.dispose() + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/docs/93_MULTI_GROUPE.md b/docs/93_MULTI_GROUPE.md index 0262dab..8564c3b 100644 --- a/docs/93_MULTI_GROUPE.md +++ b/docs/93_MULTI_GROUPE.md @@ -10,6 +10,17 @@ L'application peut servir plusieurs groupes sous une même instance. Les groupes Le groupe courant est déterminé par le membre authentifié et le `groupe_id` dans le jeton. Une requête authentifiée ne doit lire ou modifier que les données de ce groupe. +## Isolation physique + +Chaque groupe reçoit aussi un schéma PostgreSQL dédié, nommé à partir de son identifiant (`grp_`). Les tables du groupe peuvent être rafraîchies dans ce schéma avec le script: + +```bash +cd /opt/groupe-meditation/backend +venv/bin/python ../deploy/tenant_schemas.py +``` + +La sauvegarde quotidienne rafraîchit ces schémas et produit aussi un `pg_dump` par schéma. La voie applicative principale continue de filtrer par `groupe_id`; les schémas dédiés constituent la base de migration vers une exécution par `search_path` tenant. + ## Page publique La page de connexion liste les groupes sous forme de tuiles. Chaque tuile affiche l'horaire et les coordonnées du groupe, puis le formulaire de connexion. @@ -84,4 +95,3 @@ Sysadmin est transversal pour l'administration technique. Ses actions doivent ê - Vérifier que les postes et permissions ne se croisent pas. - Vérifier les rencontres, assemblées, finances, rapports et journal par groupe. - Vérifier que l'impersonification reste dans le groupe du membre ciblé. -