Durcir l'isolation multi-groupe
This commit is contained in:
parent
2e7f471643
commit
ea01dcec00
14 changed files with 187 additions and 7 deletions
32
backend/app/core/ownership.py
Normal file
32
backend/app/core/ownership.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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%"),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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%"),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue