From ea01dcec00c95eab8c0461b11d051c6932b7e5bd Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 1 Jun 2026 06:35:09 -0400 Subject: [PATCH] Durcir l'isolation multi-groupe --- backend/app/core/ownership.py | 32 +++++++++++ backend/app/main.py | 31 +++++++++++ backend/app/routers/collectes.py | 8 +++ backend/app/routers/commandes_jetons.py | 2 + backend/app/routers/jetons.py | 12 ++++- backend/app/routers/litterature.py | 2 + backend/app/routers/notifications.py | 8 ++- backend/app/routers/operations_bancaires.py | 8 +++ backend/app/routers/postes.py | 1 + backend/app/routers/presences.py | 8 +-- backend/app/routers/propositions.py | 12 ++++- backend/app/routers/rapports_rsg.py | 2 + backend/app/routers/ventes_litterature.py | 8 +++ backend/tests/test_business_rules.py | 60 +++++++++++++++++++++ 14 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 backend/app/core/ownership.py diff --git a/backend/app/core/ownership.py b/backend/app/core/ownership.py new file mode 100644 index 0000000..bb03f29 --- /dev/null +++ b/backend/app/core/ownership.py @@ -0,0 +1,32 @@ +"""Helpers de validation d'appartenance multi-groupe.""" +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +async def get_owned_or_404( + db: AsyncSession, + model, + object_id, + groupe_id, + detail: str = "Objet introuvable", + *extra_conditions, +): + """Charge un objet par id seulement s'il appartient au groupe demandé.""" + conditions = [model.id == object_id, model.groupe_id == groupe_id, *extra_conditions] + result = await db.execute(select(model).where(*conditions)) + obj = result.scalar_one_or_none() + if not obj: + raise HTTPException(status_code=404, detail=detail) + return obj + + +async def ensure_owned_or_404( + db: AsyncSession, + model, + object_id, + groupe_id, + detail: str = "Objet introuvable", + *extra_conditions, +): + return await get_owned_or_404(db, model, object_id, groupe_id, detail, *extra_conditions) diff --git a/backend/app/main.py b/backend/app/main.py index 151ef13..3cffb70 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -86,6 +86,37 @@ async def lifespan(app: FastAPI): " WHERE d.groupe_id = g.id AND d.nom = 'District 87-16'" ")" )) + for table in ( + "affectations", + "candidatures", + "collectes", + "config_repartition", + "depenses", + "destinataires_contributions", + "envois_contributions", + "evenements", + "historique_modifications", + "invitations", + "jetons", + "journal_actions", + "litterature", + "membres", + "mouvements_reserves", + "operations_bancaires", + "postes", + "propositions", + "push_subscriptions", + "rapports_adoptes", + "rapports_rsg", + "reserves", + "reunions", + "rotations", + "sync_log", + "transactions_comptables", + "ventes_jetons", + "ventes_litterature", + ): + await conn.execute(text(f"CREATE INDEX IF NOT EXISTS idx_{table}_groupe_id ON {table} (groupe_id)")) yield await engine.dispose() diff --git a/backend/app/routers/collectes.py b/backend/app/routers/collectes.py index a190586..d21191c 100644 --- a/backend/app/routers/collectes.py +++ b/backend/app/routers/collectes.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, extract, func from app.core.database import get_db +from app.core.ownership import ensure_owned_or_404 from app.core.roles import membre_a_poste_ou_executif, require_tresorier from app.core.reunions import JOURS_SEMAINE from app.core.security import get_current_membre, require_executif, is_sysadmin @@ -16,6 +17,7 @@ from app.models.envoi_contribution import EnvoiContribution from app.models.operation_bancaire import OperationBancaire from app.models.reserve import Reserve from app.models.mouvement_reserve import MouvementReserve +from app.models.reunion import Reunion from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut from app.services.finance import encaisse, solde_banque, solde_reserve, transaction from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee @@ -95,6 +97,12 @@ async def saisir_collecte( ) # Validation : une seule collecte par date de réunion + detenteur = await db.get(Membre, req.detenteur_id) + if not detenteur or detenteur.groupe_id != membre.groupe_id or not detenteur.actif: + raise HTTPException(status_code=404, detail="Détenteur introuvable ou inactif") + if req.reunion_id: + await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable") + result = await db.execute( select(Collecte).where( Collecte.groupe_id == membre.groupe_id, diff --git a/backend/app/routers/commandes_jetons.py b/backend/app/routers/commandes_jetons.py index cd9177c..19a5cc9 100644 --- a/backend/app/routers/commandes_jetons.py +++ b/backend/app/routers/commandes_jetons.py @@ -23,7 +23,9 @@ async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSessi result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", + Poste.groupe_id == membre.groupe_id, Poste.nom.ilike("%jeton%"), ) ) diff --git a/backend/app/routers/jetons.py b/backend/app/routers/jetons.py index 32ba1b7..5e5af14 100644 --- a/backend/app/routers/jetons.py +++ b/backend/app/routers/jetons.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from app.core.database import get_db +from app.core.ownership import ensure_owned_or_404 from app.core.security import get_current_membre, is_sysadmin from app.core.roles import membre_a_module from app.models.jeton import Jeton @@ -12,6 +13,7 @@ from app.models.membre import Membre from app.models.affectation import Affectation from app.models.poste import Poste from app.models.transaction_comptable import TransactionComptable +from app.models.reunion import Reunion from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS, VenteJetonCreate router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"]) @@ -24,7 +26,9 @@ async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSessi result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", + Poste.groupe_id == membre.groupe_id, (Poste.nom.ilike("%jeton%") | (Poste.categorie == "executif")), ) ) @@ -58,9 +62,11 @@ async def remettre_jeton( membre_prenom = None if req.membre_id: cible = await db.get(Membre, req.membre_id) - if not cible or not cible.actif: + if not cible or cible.groupe_id != membre.groupe_id or not cible.actif: raise HTTPException(status_code=404, detail="Membre introuvable") membre_prenom = cible.prenom + if req.reunion_id: + await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable") jeton = Jeton( groupe_id=membre.groupe_id, @@ -103,6 +109,8 @@ async def enregistrer_vente_jeton( vendeur = await db.get(Membre, vendeur_id) if not vendeur or vendeur.groupe_id != membre.groupe_id or not vendeur.actif: raise HTTPException(status_code=404, detail="Vendeur introuvable") + if req.reunion_id: + await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable") vente = VenteJeton( groupe_id=membre.groupe_id, @@ -160,7 +168,9 @@ async def confirmer_reception_vente_jeton( result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", + Poste.groupe_id == membre.groupe_id, (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), ) ) diff --git a/backend/app/routers/litterature.py b/backend/app/routers/litterature.py index e686953..f556fea 100644 --- a/backend/app/routers/litterature.py +++ b/backend/app/routers/litterature.py @@ -22,7 +22,9 @@ async def require_resp_litterature(membre=Depends(get_current_membre), db: Async result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", + Poste.groupe_id == membre.groupe_id, Poste.nom.ilike("%littérature%"), ) ) diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py index 963fc1b..a8216a9 100644 --- a/backend/app/routers/notifications.py +++ b/backend/app/routers/notifications.py @@ -100,6 +100,7 @@ async def retirer_abonnement( select(PushSubscription).where( PushSubscription.endpoint == endpoint, PushSubscription.membre_id == membre.id, + PushSubscription.groupe_id == membre.groupe_id, ) ) sub = result.scalar_one_or_none() @@ -115,7 +116,12 @@ async def envoyer_test( membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - result = await db.execute(select(PushSubscription).where(PushSubscription.membre_id == membre.id)) + result = await db.execute( + select(PushSubscription).where( + PushSubscription.membre_id == membre.id, + PushSubscription.groupe_id == membre.groupe_id, + ) + ) sent = await send_to_subscriptions(db, result.scalars().all(), { "title": "Notifications activées", "body": "Cet appareil recevra les avis du groupe.", diff --git a/backend/app/routers/operations_bancaires.py b/backend/app/routers/operations_bancaires.py index 606714a..2cddd76 100644 --- a/backend/app/routers/operations_bancaires.py +++ b/backend/app/routers/operations_bancaires.py @@ -263,6 +263,12 @@ async def liste_mouvements_reserves( source = await db.get(Reserve, mv.source_id) if mv.source_id else None destination = await db.get(Reserve, mv.destination_id) if mv.destination_id else None createur = await db.get(Membre, mv.cree_par) if mv.cree_par else None + if source and source.groupe_id != membre.groupe_id: + source = None + if destination and destination.groupe_id != membre.groupe_id: + destination = None + if createur and createur.groupe_id != membre.groupe_id: + createur = None out.append(ReserveMouvementOut( id=mv.id, source_id=mv.source_id, @@ -376,6 +382,8 @@ async def liste_operations( out = [] for op in ops: m = await db.get(Membre, op.effectue_par) + if m and m.groupe_id != membre.groupe_id: + m = None out.append(OperationBancaireOut( id=op.id, type=op.type, diff --git a/backend/app/routers/postes.py b/backend/app/routers/postes.py index 17d9e68..df760c2 100644 --- a/backend/app/routers/postes.py +++ b/backend/app/routers/postes.py @@ -563,6 +563,7 @@ async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession Affectation.membre_id == membre.id, Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", + Poste.groupe_id == membre.groupe_id, ) ) out = [] diff --git a/backend/app/routers/presences.py b/backend/app/routers/presences.py index 733010f..47fb196 100644 --- a/backend/app/routers/presences.py +++ b/backend/app/routers/presences.py @@ -245,7 +245,7 @@ async def lire_assemblee_invite( if invitation.expire_le < datetime.now(timezone.utc): raise HTTPException(status_code=410, detail="Invitation expirée") reunion = await db.get(Reunion, invitation.cible_id) - if not reunion or reunion.type != "affaires": + if not reunion or reunion.groupe_id != invitation.groupe_id or reunion.type != "affaires": raise HTTPException(status_code=404, detail="Assemblée introuvable") return await _assemblee_publique(reunion, db) @@ -411,16 +411,18 @@ async def enregistrer_presences( # Insérer les nouvelles out = [] for item in req.presences: + mbr = await db.get(Membre, item.membre_id) + if not mbr or mbr.groupe_id != membre.groupe_id or not mbr.actif: + raise HTTPException(status_code=404, detail="Membre introuvable") pres = Presence( reunion_id=req.reunion_id, membre_id=item.membre_id, present=item.present, ) db.add(pres) - mbr = await db.get(Membre, item.membre_id) out.append(PresenceOut( membre_id=item.membre_id, - membre_prenom=mbr.prenom if mbr else "?", + membre_prenom=mbr.prenom, present=item.present, )) await db.flush() diff --git a/backend/app/routers/propositions.py b/backend/app/routers/propositions.py index 8a9d0a9..d398c0d 100644 --- a/backend/app/routers/propositions.py +++ b/backend/app/routers/propositions.py @@ -7,6 +7,7 @@ from sqlalchemy import select, desc, extract from app.core.database import get_db from app.core.decisions import prochain_numero_decision from app.core.decision_types import type_decision_depuis_proposition +from app.core.ownership import ensure_owned_or_404 from app.core.security import get_current_membre, is_sysadmin, require_executif from app.core.roles import membre_a_poste_ou_executif from app.models.proposition import Proposition @@ -176,12 +177,19 @@ async def voter_proposition( executif.groupe_id, type_decision, date.today().year, db ) if req.reunion_id and not prop.reunion_id: + await ensure_owned_or_404(db, Reunion, req.reunion_id, executif.groupe_id, "Réunion introuvable") prop.reunion_id = req.reunion_id # ── NOMINATION : créer l'affectation automatiquement ── if type_decision == "nomination" and prop.candidature_id: cand = await db.get(Candidature, prop.candidature_id) - if cand and cand.statut == "proposee": + if cand and cand.groupe_id == executif.groupe_id and cand.statut == "proposee": + membre_cible = await db.get(Membre, cand.membre_id) + poste = await db.get(Poste, cand.poste_id) + if not membre_cible or membre_cible.groupe_id != executif.groupe_id: + raise HTTPException(status_code=404, detail="Membre de nomination introuvable") + if not poste or poste.groupe_id != executif.groupe_id: + raise HTTPException(status_code=404, detail="Poste de nomination introuvable") cand.statut = "acceptee" cand.proposition_id = prop.id affectation = Affectation( @@ -204,7 +212,7 @@ async def voter_proposition( # Si nomination rejetée, mettre la candidature en refusée if type_decision_depuis_proposition(prop) == "nomination" and prop.candidature_id: cand = await db.get(Candidature, prop.candidature_id) - if cand: + if cand and cand.groupe_id == executif.groupe_id: cand.statut = "refusee" prop.statut = req.statut diff --git a/backend/app/routers/rapports_rsg.py b/backend/app/routers/rapports_rsg.py index f90454d..411e180 100644 --- a/backend/app/routers/rapports_rsg.py +++ b/backend/app/routers/rapports_rsg.py @@ -21,7 +21,9 @@ async def require_rsg(membre=Depends(get_current_membre), db=Depends(get_db)): result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", + Poste.groupe_id == membre.groupe_id, (Poste.nom.ilike("%RSG%") | (Poste.categorie == "executif")), ) ) diff --git a/backend/app/routers/ventes_litterature.py b/backend/app/routers/ventes_litterature.py index 420ebb3..094012c 100644 --- a/backend/app/routers/ventes_litterature.py +++ b/backend/app/routers/ventes_litterature.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from app.core.database import get_db +from app.core.ownership import ensure_owned_or_404 from app.core.security import get_current_membre, is_sysadmin from app.core.roles import membre_a_module from app.models.litterature import Litterature @@ -12,6 +13,7 @@ from app.models.membre import Membre from app.models.affectation import Affectation from app.models.poste import Poste from app.models.transaction_comptable import TransactionComptable +from app.models.reunion import Reunion from app.schemas.phase6 import VenteCreate, VenteOut router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littérature"]) @@ -24,7 +26,9 @@ async def require_resp_litterature(membre=Depends(get_current_membre), db: Async result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", + Poste.groupe_id == membre.groupe_id, (Poste.nom.ilike("%littérature%") | (Poste.categorie == "executif")), ) ) @@ -67,6 +71,8 @@ async def enregistrer_vente( vendeur = await db.get(Membre, vendeur_id) if not vendeur or vendeur.groupe_id != membre.groupe_id or not vendeur.actif: raise HTTPException(status_code=404, detail="Vendeur introuvable") + if req.reunion_id: + await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable") # Créer la vente vente = VenteLitterature( @@ -165,7 +171,9 @@ async def confirmer_reception_vente( result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", + Poste.groupe_id == membre.groupe_id, (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), ) ) diff --git a/backend/tests/test_business_rules.py b/backend/tests/test_business_rules.py index 28f563e..ae09d2c 100644 --- a/backend/tests/test_business_rules.py +++ b/backend/tests/test_business_rules.py @@ -9,6 +9,19 @@ from app.core.decision_types import type_decision_depuis_proposition from app.core.financial_position import PositionTresorerie from app.core.sanitization import safe_query_params from app.models.poste import Poste +from app.models.affectation import Affectation +from app.models.candidature import Candidature +from app.models.collecte import Collecte +from app.models.depense import Depense +from app.models.evenement import Evenement +from app.models.jeton import Jeton +from app.models.litterature import Litterature +from app.models.presence import Presence +from app.models.proposition import Proposition +from app.models.pv_reunion import PvReunion +from app.models.reunion import Reunion +from app.models.vente_litterature import VenteLitterature +from app.models.vente_jeton import VenteJeton class DecisionTypeTests(unittest.TestCase): @@ -52,6 +65,53 @@ class MultiTenantTests(unittest.TestCase): self.assertIn("groupe_id", Poste.__table__.columns) self.assertFalse(Poste.__table__.columns["groupe_id"].nullable) + def test_core_business_tables_are_group_scoped(self): + for model in [ + Affectation, + Candidature, + Collecte, + Depense, + Evenement, + Jeton, + Litterature, + Proposition, + Reunion, + VenteLitterature, + VenteJeton, + ]: + with self.subTest(model=model.__name__): + self.assertIn("groupe_id", model.__table__.columns) + self.assertFalse(model.__table__.columns["groupe_id"].nullable) + + def test_child_tables_without_group_id_are_documented_exceptions(self): + exceptions = { + "presences": "scoped_by_reunion", + "pv_reunions": "scoped_by_reunion", + } + for model in [Presence, PvReunion]: + with self.subTest(model=model.__name__): + self.assertNotIn("groupe_id", model.__table__.columns) + self.assertIn(model.__tablename__, exceptions) + + def test_role_fallback_queries_are_group_bounded(self): + root = Path(__file__).resolve().parents[1] + for rel in [ + "app/routers/jetons.py", + "app/routers/ventes_litterature.py", + "app/routers/litterature.py", + "app/routers/rapports_rsg.py", + "app/routers/commandes_jetons.py", + ]: + source = (root / rel).read_text(encoding="utf-8") + with self.subTest(router=rel): + self.assertIn("Affectation.groupe_id == membre.groupe_id", source) + self.assertIn("Poste.groupe_id == membre.groupe_id", source) + + def test_presence_batch_validates_members_in_current_group(self): + source = (Path(__file__).resolve().parents[1] / "app/routers/presences.py").read_text(encoding="utf-8") + self.assertIn("mbr.groupe_id != membre.groupe_id", source) + self.assertIn("reunion.groupe_id != invitation.groupe_id", source) + if __name__ == "__main__": unittest.main()