Durcir l'isolation multi-groupe

This commit is contained in:
Daniel Allaire 2026-06-01 06:35:09 -04:00
parent 2e7f471643
commit ea01dcec00
14 changed files with 187 additions and 7 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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,

View file

@ -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%"),
)
)

View file

@ -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")),
)
)

View file

@ -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%"),
)
)

View file

@ -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.",

View file

@ -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,

View file

@ -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 = []

View file

@ -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()

View file

@ -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

View file

@ -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")),
)
)

View file

@ -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")),
)
)

View file

@ -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()