Renforcer isolation multi-tenant

- scoper les postes et permissions par groupe

- retirer les lectures district du calendrier, des evenements et anniversaires

- documenter les regles d isolation multi-groupe
This commit is contained in:
Daniel Allaire 2026-05-31 18:12:26 -04:00
parent 9b45a3110f
commit 393f9243cb
16 changed files with 164 additions and 73 deletions

View file

@ -15,10 +15,14 @@ async def membre_a_poste_ou_executif(membre_id, libelle_poste: str, db: AsyncSes
membre = await db.get(Membre, membre_id) membre = await db.get(Membre, membre_id)
if membre and is_sysadmin(membre): if membre and is_sysadmin(membre):
return True return True
if not membre:
return False
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
Affectation.membre_id == membre_id, Affectation.membre_id == membre_id,
Affectation.groupe_id == membre.groupe_id,
Affectation.statut == "actif", Affectation.statut == "actif",
Poste.groupe_id == membre.groupe_id,
(Poste.nom.ilike(f"%{libelle_poste}%") | (Poste.categorie == "executif")), (Poste.nom.ilike(f"%{libelle_poste}%") | (Poste.categorie == "executif")),
) )
) )
@ -30,10 +34,14 @@ async def membre_a_module(membre_id, module_code: str, db: AsyncSession) -> bool
membre = await db.get(Membre, membre_id) membre = await db.get(Membre, membre_id)
if membre and is_sysadmin(membre): if membre and is_sysadmin(membre):
return True return True
if not membre:
return False
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).join(PosteModule, PosteModule.poste_id == Poste.id).where( select(Affectation).join(Poste).join(PosteModule, PosteModule.poste_id == Poste.id).where(
Affectation.membre_id == membre_id, Affectation.membre_id == membre_id,
Affectation.groupe_id == membre.groupe_id,
Affectation.statut == "actif", Affectation.statut == "actif",
Poste.groupe_id == membre.groupe_id,
PosteModule.module_code == module_code, PosteModule.module_code == module_code,
) )
) )

View file

@ -73,7 +73,9 @@ async def require_executif(
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id, Affectation.membre_id == membre.id,
Affectation.groupe_id == membre.groupe_id,
Affectation.statut == "actif", Affectation.statut == "actif",
Poste.groupe_id == membre.groupe_id,
Poste.categorie == "executif", Poste.categorie == "executif",
) )
) )

View file

@ -39,6 +39,12 @@ async def lifespan(app: FastAPI):
"ALTER TABLE groupes " "ALTER TABLE groupes "
"ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'" "ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'"
)) ))
await conn.execute(text("ALTER TABLE postes ADD COLUMN IF NOT EXISTS groupe_id UUID REFERENCES groupes(id)"))
await conn.execute(text(
"UPDATE postes SET groupe_id = (SELECT id FROM groupes ORDER BY cree_le LIMIT 1) "
"WHERE groupe_id IS NULL AND EXISTS (SELECT 1 FROM groupes)"
))
await conn.execute(text("ALTER TABLE postes ALTER COLUMN groupe_id SET NOT NULL"))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS date_adoption DATE")) await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS date_adoption DATE"))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS etant_donne_que TEXT")) await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS etant_donne_que TEXT"))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT")) await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT"))

View file

@ -1,6 +1,6 @@
"""Table postes.""" """Table postes."""
import uuid import uuid
from sqlalchemy import String, Text, Integer, Boolean, Interval, Enum as SAEnum from sqlalchemy import String, Text, Integer, Boolean, Interval, Enum as SAEnum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
@ -9,6 +9,7 @@ class Poste(Base):
__tablename__ = "postes" __tablename__ = "postes"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
nom: Mapped[str] = mapped_column(String(100), nullable=False) nom: Mapped[str] = mapped_column(String(100), nullable=False)
categorie: Mapped[str] = mapped_column(SAEnum("executif", "service", "physique", name="categorie_poste"), nullable=False) categorie: Mapped[str] = mapped_column(SAEnum("executif", "service", "physique", name="categorie_poste"), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)

View file

@ -112,7 +112,9 @@ async def accueil(
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id, Affectation.membre_id == membre.id,
Affectation.groupe_id == membre.groupe_id,
Affectation.statut == "actif", Affectation.statut == "actif",
Poste.groupe_id == membre.groupe_id,
) )
) )
affectations = result.scalars().all() affectations = result.scalars().all()
@ -122,7 +124,7 @@ async def accueil(
poste_ids = [] poste_ids = []
for a in affectations: for a in affectations:
poste = await db.get(Poste, a.poste_id) poste = await db.get(Poste, a.poste_id)
if poste: if poste and poste.groupe_id == membre.groupe_id:
postes_noms.append(poste.nom) postes_noms.append(poste.nom)
postes_categories.append(poste.categorie) postes_categories.append(poste.categorie)
poste_ids.append(poste.id) poste_ids.append(poste.id)

View file

@ -1,4 +1,4 @@
"""Routes C3 — Anniversaires district 87-16.""" """Routes C3 — Anniversaires du groupe."""
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -10,7 +10,7 @@ from app.models.membre import Membre
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.schemas.phase5 import AnniversaireOut from app.schemas.phase5 import AnniversaireOut
router = APIRouter(prefix="/anniversaires", tags=["C3 — Anniversaires district"]) router = APIRouter(prefix="/anniversaires", tags=["C3 — Anniversaires"])
def _calcul_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, date, int]: def _calcul_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, date, int]:
@ -45,39 +45,36 @@ def _calcul_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int,
@router.get("/", response_model=list[AnniversaireOut]) @router.get("/", response_model=list[AnniversaireOut])
async def anniversaires_district( async def anniversaires_groupe(
prochains_jours: int = 90, prochains_jours: int = 90,
membre=Depends(get_current_membre), membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""C3 — Anniversaires de sobriété de tous les groupes du district 87-16.""" """C3 — Anniversaires de sobriété du groupe courant."""
aujourdhui = date.today() aujourdhui = date.today()
# Récupérer le district du groupe du membre
groupe = await db.get(Groupe, membre.groupe_id) groupe = await db.get(Groupe, membre.groupe_id)
district = groupe.district if groupe else "87-16" if not groupe:
return []
# Tous les membres actifs du district ayant une date d'abstinence
result = await db.execute( result = await db.execute(
select(Membre, Groupe.nom) select(Membre)
.join(Groupe, Membre.groupe_id == Groupe.id)
.where( .where(
Groupe.district == district, Membre.groupe_id == membre.groupe_id,
Membre.actif == True, Membre.actif == True,
Membre.date_abstinence.isnot(None), Membre.date_abstinence.isnot(None),
) )
) )
rows = result.all()
anniversaires = [] anniversaires = []
for m, groupe_nom in rows: for m in result.scalars().all():
annees_sobriete, prochain, jours_restants = _calcul_anniversaire(m.date_abstinence, aujourdhui) annees_sobriete, prochain, jours_restants = _calcul_anniversaire(m.date_abstinence, aujourdhui)
# Filtrer : prochains X jours OU anniversaire aujourd'hui # Filtrer : prochains X jours OU anniversaire aujourd'hui
if 0 <= jours_restants <= prochains_jours: if 0 <= jours_restants <= prochains_jours:
anniversaires.append(AnniversaireOut( anniversaires.append(AnniversaireOut(
membre_prenom=m.prenom, membre_prenom=m.prenom,
groupe_nom=groupe_nom, groupe_nom=groupe.nom,
date_abstinence=m.date_abstinence, date_abstinence=m.date_abstinence,
annees_sobriete=annees_sobriete + 1, # prochain anniversaire = +1 annees_sobriete=annees_sobriete + 1, # prochain anniversaire = +1
prochain_anniversaire=prochain, prochain_anniversaire=prochain,

View file

@ -1,4 +1,4 @@
"""Routes C2 — Calendrier district 87-16.""" """Routes C2 — Calendrier du groupe."""
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@ -11,29 +11,22 @@ from app.models.poste import Poste
from app.models.membre import Membre from app.models.membre import Membre
from app.schemas.phase2 import CalendrierEntry from app.schemas.phase2 import CalendrierEntry
router = APIRouter(prefix="/calendrier", tags=["C2 — Calendrier district"]) router = APIRouter(prefix="/calendrier", tags=["C2 — Calendrier"])
@router.get("/", response_model=list[CalendrierEntry]) @router.get("/", response_model=list[CalendrierEntry])
async def calendrier_district( async def calendrier_groupe_courant(
membre=Depends(get_current_membre), membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""C2 — Calendrier des serviteurs de tous les groupes du district 87-16.""" """C2 — Calendrier des serviteurs du groupe courant."""
# Récupérer le district du membre
groupe = await db.get(Groupe, membre.groupe_id) groupe = await db.get(Groupe, membre.groupe_id)
district = groupe.district if groupe else "87-16" if not groupe:
return []
# Tous les groupes du même district
result = await db.execute(
select(Groupe).where(Groupe.district == district).order_by(Groupe.nom)
)
groupes = {g.id: g for g in result.scalars().all()}
# Toutes les affectations actives de ces groupes
result = await db.execute( result = await db.execute(
select(Affectation).where( select(Affectation).where(
Affectation.groupe_id.in_(groupes.keys()), Affectation.groupe_id == membre.groupe_id,
Affectation.statut == "actif", Affectation.statut == "actif",
) )
) )
@ -43,18 +36,16 @@ async def calendrier_district(
for a in affectations: for a in affectations:
poste = await db.get(Poste, a.poste_id) poste = await db.get(Poste, a.poste_id)
mbr = await db.get(Membre, a.membre_id) mbr = await db.get(Membre, a.membre_id)
grp = groupes.get(a.groupe_id) if poste and poste.groupe_id == membre.groupe_id and mbr and mbr.groupe_id == membre.groupe_id:
if poste and mbr and grp:
entries.append(CalendrierEntry( entries.append(CalendrierEntry(
groupe_nom=grp.nom, groupe_nom=groupe.nom,
groupe_id=grp.id, groupe_id=groupe.id,
jour_reunion=grp.jour_reunion, jour_reunion=groupe.jour_reunion,
poste_nom=poste.nom, poste_nom=poste.nom,
membre_prenom=mbr.prenom, membre_prenom=mbr.prenom,
date_debut=a.date_debut, date_debut=a.date_debut,
)) ))
# Trier par groupe puis par ordre de poste
entries.sort(key=lambda e: (e.groupe_nom, e.poste_nom)) entries.sort(key=lambda e: (e.groupe_nom, e.poste_nom))
return entries return entries
@ -65,14 +56,16 @@ async def calendrier_groupe(
membre=Depends(get_current_membre), membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Détail des serviteurs d'un groupe spécifique.""" """Détail des serviteurs du groupe courant uniquement."""
groupe = await db.get(Groupe, groupe_id) if str(groupe_id) != str(membre.groupe_id):
return {"groupe": None, "serviteurs": []}
groupe = await db.get(Groupe, membre.groupe_id)
if not groupe: if not groupe:
return {"groupe": None, "serviteurs": []} return {"groupe": None, "serviteurs": []}
result = await db.execute( result = await db.execute(
select(Affectation).where( select(Affectation).where(
Affectation.groupe_id == groupe_id, Affectation.groupe_id == membre.groupe_id,
Affectation.statut == "actif", Affectation.statut == "actif",
) )
) )
@ -82,7 +75,7 @@ async def calendrier_groupe(
for a in affectations: for a in affectations:
poste = await db.get(Poste, a.poste_id) poste = await db.get(Poste, a.poste_id)
mbr = await db.get(Membre, a.membre_id) mbr = await db.get(Membre, a.membre_id)
if poste and mbr: if poste and poste.groupe_id == membre.groupe_id and mbr and mbr.groupe_id == membre.groupe_id:
serviteurs.append({ serviteurs.append({
"poste": poste.nom, "poste": poste.nom,
"categorie": poste.categorie, "categorie": poste.categorie,

View file

@ -35,21 +35,21 @@ async def evenements_a_venir(
debut = aujourdhui - timedelta(days=jours_avant) debut = aujourdhui - timedelta(days=jours_avant)
limite = aujourdhui + timedelta(days=jours) limite = aujourdhui + timedelta(days=jours)
groupe = await db.get(Groupe, membre.groupe_id) groupe = await db.get(Groupe, membre.groupe_id)
district = groupe.district if groupe else "87-16" if not groupe:
return []
evenements = [] evenements = []
# ── 1. Anniversaires de sobriété du district ── # ── 1. Anniversaires de sobriété du groupe ──
result = await db.execute( result = await db.execute(
select(Membre, Groupe.nom) select(Membre)
.join(Groupe, Membre.groupe_id == Groupe.id)
.where( .where(
Groupe.district == district, Membre.groupe_id == membre.groupe_id,
Membre.actif == True, Membre.actif == True,
Membre.date_abstinence.isnot(None), Membre.date_abstinence.isnot(None),
) )
) )
for m, groupe_nom in result.all(): for m in result.scalars().all():
for annee in range(debut.year, limite.year + 1): for annee in range(debut.year, limite.year + 1):
annees, anniv = _anniversaire_sobriete(m.date_abstinence, annee) annees, anniv = _anniversaire_sobriete(m.date_abstinence, annee)
if anniv < debut or anniv > limite: if anniv < debut or anniv > limite:
@ -58,29 +58,28 @@ async def evenements_a_venir(
"type": "anniversaire", "type": "anniversaire",
"date": str(anniv), "date": str(anniv),
"titre": f"🎂 {m.prenom} — {annees} an{'s' if annees > 1 else ''}", "titre": f"🎂 {m.prenom} — {annees} an{'s' if annees > 1 else ''}",
"detail": f"Anniversaire de sobriété · {groupe_nom}", "detail": f"Anniversaire de sobriété · {groupe.nom}",
"lieu": None, "lieu": None,
"groupe_nom": groupe_nom, "groupe_nom": groupe.nom,
}) })
# ── 2. Événements ad hoc ── # ── 2. Événements ad hoc ──
result = await db.execute( result = await db.execute(
select(Evenement, Groupe.nom) select(Evenement)
.join(Groupe, Evenement.groupe_id == Groupe.id)
.where( .where(
Groupe.district == district, Evenement.groupe_id == membre.groupe_id,
Evenement.date_evenement >= debut, Evenement.date_evenement >= debut,
Evenement.date_evenement <= limite, Evenement.date_evenement <= limite,
) )
) )
for ev, groupe_nom in result.all(): for ev in result.scalars().all():
evenements.append({ evenements.append({
"type": "adhoc", "type": "adhoc",
"date": str(ev.date_evenement), "date": str(ev.date_evenement),
"titre": ev.titre, "titre": ev.titre,
"detail": ev.description, "detail": ev.description,
"lieu": ev.lieu, "lieu": ev.lieu,
"groupe_nom": groupe_nom, "groupe_nom": groupe.nom,
}) })
# Trier par date # Trier par date

View file

@ -23,9 +23,9 @@ router = APIRouter(prefix="/postes", tags=["Postes et affectations"])
async def liste_postes( async def liste_postes(
inclure_abolis: bool = False, inclure_abolis: bool = False,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_=Depends(get_current_membre), membre=Depends(get_current_membre),
): ):
query = select(Poste) query = select(Poste).where(Poste.groupe_id == membre.groupe_id)
if not inclure_abolis: if not inclure_abolis:
query = query.where(Poste.actif == True) query = query.where(Poste.actif == True)
result = await db.execute(query.order_by(Poste.ordre_affichage)) result = await db.execute(query.order_by(Poste.ordre_affichage))
@ -44,6 +44,7 @@ async def creer_poste(
if req.categorie not in ("executif", "service", "physique"): if req.categorie not in ("executif", "service", "physique"):
raise HTTPException(status_code=400, detail="Catégorie invalide") raise HTTPException(status_code=400, detail="Catégorie invalide")
poste = Poste( poste = Poste(
groupe_id=executif.groupe_id,
nom=req.nom.strip(), nom=req.nom.strip(),
categorie=req.categorie, categorie=req.categorie,
description=req.description, description=req.description,
@ -63,7 +64,8 @@ async def modifier_poste(
executif=Depends(require_executif), executif=Depends(require_executif),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
poste = await db.get(Poste, poste_id) result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
poste = result.scalar_one_or_none()
if not poste: if not poste:
raise HTTPException(status_code=404, detail="Poste introuvable") raise HTTPException(status_code=404, detail="Poste introuvable")
if req.categorie is not None and req.categorie not in ("executif", "service", "physique"): if req.categorie is not None and req.categorie not in ("executif", "service", "physique"):
@ -85,7 +87,8 @@ async def abolir_poste(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Abolir un poste — termine tous les mandats actifs associés.""" """Abolir un poste — termine tous les mandats actifs associés."""
poste = await db.get(Poste, poste_id) result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
poste = result.scalar_one_or_none()
if not poste: if not poste:
raise HTTPException(status_code=404, detail="Poste introuvable") raise HTTPException(status_code=404, detail="Poste introuvable")
@ -93,6 +96,7 @@ async def abolir_poste(
result = await db.execute( result = await db.execute(
select(Affectation).where( select(Affectation).where(
Affectation.poste_id == poste.id, Affectation.poste_id == poste.id,
Affectation.groupe_id == executif.groupe_id,
Affectation.statut == "actif", Affectation.statut == "actif",
) )
) )
@ -108,6 +112,7 @@ async def abolir_poste(
result = await db.execute( result = await db.execute(
select(Candidature).where( select(Candidature).where(
Candidature.poste_id == poste.id, Candidature.poste_id == poste.id,
Candidature.groupe_id == executif.groupe_id,
Candidature.statut == "proposee", Candidature.statut == "proposee",
) )
) )
@ -131,7 +136,7 @@ async def postes_disponibles(
): ):
"""Postes actifs sans titulaire — opportunités d'implication.""" """Postes actifs sans titulaire — opportunités d'implication."""
result = await db.execute( result = await db.execute(
select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage) select(Poste).where(Poste.groupe_id == membre.groupe_id, Poste.actif == True).order_by(Poste.ordre_affichage)
) )
postes = result.scalars().all() postes = result.scalars().all()
@ -163,22 +168,23 @@ async def postes_disponibles(
"prerequis": p.prerequis, "prerequis": p.prerequis,
"informations": p.informations, "informations": p.informations,
"deja_postule": deja_postule, "deja_postule": deja_postule,
"candidats": await _candidats_poste(p.id, db), "candidats": await _candidats_poste(p.id, membre.groupe_id, db),
}) })
return disponibles return disponibles
async def _candidats_poste(poste_id, db: AsyncSession): async def _candidats_poste(poste_id, groupe_id, db: AsyncSession):
result = await db.execute( result = await db.execute(
select(Candidature).where( select(Candidature).where(
Candidature.poste_id == poste_id, Candidature.poste_id == poste_id,
Candidature.groupe_id == groupe_id,
Candidature.statut == "proposee", Candidature.statut == "proposee",
) )
) )
candidats = [] candidats = []
for c in result.scalars().all(): for c in result.scalars().all():
membre = await db.get(Membre, c.membre_id) membre = await db.get(Membre, c.membre_id)
if membre: if membre and membre.groupe_id == groupe_id:
candidats.append({ candidats.append({
"id": str(c.id), "id": str(c.id),
"membre_id": str(membre.id), "membre_id": str(membre.id),
@ -201,7 +207,8 @@ async def postuler(
"""Proposer sa candidature — crée automatiquement une proposition de nomination.""" """Proposer sa candidature — crée automatiquement une proposition de nomination."""
from app.models.proposition import Proposition from app.models.proposition import Proposition
poste = await db.get(Poste, poste_id) result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == membre.groupe_id))
poste = result.scalar_one_or_none()
if not poste or not poste.actif: if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
@ -209,6 +216,7 @@ async def postuler(
result = await db.execute( result = await db.execute(
select(Candidature).where( select(Candidature).where(
Candidature.poste_id == poste_id, Candidature.poste_id == poste_id,
Candidature.groupe_id == membre.groupe_id,
Candidature.membre_id == membre.id, Candidature.membre_id == membre.id,
Candidature.statut == "proposee", Candidature.statut == "proposee",
) )
@ -256,7 +264,8 @@ async def nominer(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Nominer quelqu'un (exécutif). Le nominé devra accepter avant que la proposition soit créée.""" """Nominer quelqu'un (exécutif). Le nominé devra accepter avant que la proposition soit créée."""
poste = await db.get(Poste, poste_id) result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
poste = result.scalar_one_or_none()
if not poste or not poste.actif: if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
nominee = await db.get(Membre, membre_id) nominee = await db.get(Membre, membre_id)
@ -288,7 +297,8 @@ async def proposer_membre(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Proposer un autre membre pour un poste.""" """Proposer un autre membre pour un poste."""
poste = await db.get(Poste, poste_id) result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == membre.groupe_id))
poste = result.scalar_one_or_none()
if not poste or not poste.actif: if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
cible = await db.get(Membre, membre_id) cible = await db.get(Membre, membre_id)
@ -300,6 +310,7 @@ async def proposer_membre(
result = await db.execute( result = await db.execute(
select(Candidature).where( select(Candidature).where(
Candidature.poste_id == poste_id, Candidature.poste_id == poste_id,
Candidature.groupe_id == membre.groupe_id,
Candidature.membre_id == membre_id, Candidature.membre_id == membre_id,
Candidature.statut == "proposee", Candidature.statut == "proposee",
) )
@ -332,6 +343,7 @@ async def retirer_candidature(
result = await db.execute( result = await db.execute(
select(Candidature).where( select(Candidature).where(
Candidature.id == candidature_id, Candidature.id == candidature_id,
Candidature.groupe_id == membre.groupe_id,
Candidature.membre_id == membre.id, Candidature.membre_id == membre.id,
Candidature.statut == "proposee", Candidature.statut == "proposee",
) )
@ -365,6 +377,7 @@ async def accepter_nomination(
result = await db.execute( result = await db.execute(
select(Candidature).where( select(Candidature).where(
Candidature.id == candidature_id, Candidature.id == candidature_id,
Candidature.groupe_id == membre.groupe_id,
Candidature.membre_id == membre.id, Candidature.membre_id == membre.id,
Candidature.type == "nomination", Candidature.type == "nomination",
Candidature.statut == "proposee", Candidature.statut == "proposee",
@ -383,6 +396,8 @@ async def accepter_nomination(
# Acceptée → créer la proposition de nomination # Acceptée → créer la proposition de nomination
poste = await db.get(Poste, cand.poste_id) poste = await db.get(Poste, cand.poste_id)
if not poste or poste.groupe_id != membre.groupe_id:
raise HTTPException(status_code=404, detail="Poste introuvable")
nomme_par = await db.get(Membre, cand.nomme_par) if cand.nomme_par else None nomme_par = await db.get(Membre, cand.nomme_par) if cand.nomme_par else None
prop = Proposition( prop = Proposition(
@ -421,6 +436,8 @@ async def liste_candidatures(
mbr = await db.get(Membre, c.membre_id) mbr = await db.get(Membre, c.membre_id)
poste = await db.get(Poste, c.poste_id) poste = await db.get(Poste, c.poste_id)
nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None
if poste and poste.groupe_id != executif.groupe_id:
continue
out.append({ out.append({
"id": str(c.id), "id": str(c.id),
"membre_prenom": mbr.prenom if mbr else "?", "membre_prenom": mbr.prenom if mbr else "?",
@ -443,6 +460,7 @@ async def mes_candidatures(
result = await db.execute( result = await db.execute(
select(Candidature).where( select(Candidature).where(
Candidature.membre_id == membre.id, Candidature.membre_id == membre.id,
Candidature.groupe_id == membre.groupe_id,
Candidature.statut == "proposee", Candidature.statut == "proposee",
) )
) )
@ -450,6 +468,8 @@ async def mes_candidatures(
for c in result.scalars().all(): for c in result.scalars().all():
poste = await db.get(Poste, c.poste_id) poste = await db.get(Poste, c.poste_id)
nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None
if poste and poste.groupe_id != membre.groupe_id:
continue
out.append({ out.append({
"id": str(c.id), "id": str(c.id),
"poste_nom": poste.nom if poste else "?", "poste_nom": poste.nom if poste else "?",
@ -493,7 +513,7 @@ async def traiter_candidature(
poste = await db.get(Poste, cand.poste_id) poste = await db.get(Poste, cand.poste_id)
if not membre_cible or not membre_cible.actif or membre_cible.groupe_id != executif.groupe_id: if not membre_cible or not membre_cible.actif or membre_cible.groupe_id != executif.groupe_id:
raise HTTPException(status_code=404, detail="Membre à affecter introuvable ou inactif") raise HTTPException(status_code=404, detail="Membre à affecter introuvable ou inactif")
if not poste or not poste.actif: if not poste or poste.groupe_id != executif.groupe_id or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
result = await db.execute( result = await db.execute(
select(Affectation).where( select(Affectation).where(
@ -541,6 +561,7 @@ async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id, Affectation.membre_id == membre.id,
Affectation.groupe_id == membre.groupe_id,
Affectation.statut == "actif", Affectation.statut == "actif",
) )
) )
@ -597,7 +618,7 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe
raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif") raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif")
if not secondeur or secondeur.groupe_id != executif.groupe_id or not secondeur.actif: if not secondeur or secondeur.groupe_id != executif.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif") raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
if not poste or not poste.actif: if not poste or poste.groupe_id != executif.groupe_id or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
result = await db.execute( result = await db.execute(
@ -675,7 +696,8 @@ async def reactiver_poste(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Réactiver un poste aboli.""" """Réactiver un poste aboli."""
poste = await db.get(Poste, poste_id) result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
poste = result.scalar_one_or_none()
if not poste: if not poste:
raise HTTPException(status_code=404, detail="Poste introuvable") raise HTTPException(status_code=404, detail="Poste introuvable")
if poste.actif: if poste.actif:
@ -692,7 +714,8 @@ async def supprimer_poste_permanent(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Supprimer définitivement un poste aboli (irréversible).""" """Supprimer définitivement un poste aboli (irréversible)."""
poste = await db.get(Poste, poste_id) result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
poste = result.scalar_one_or_none()
if not poste: if not poste:
raise HTTPException(status_code=404, detail="Poste introuvable") raise HTTPException(status_code=404, detail="Poste introuvable")
if poste.actif: if poste.actif:
@ -710,7 +733,11 @@ async def modules_par_poste(
executif=Depends(require_executif), executif=Depends(require_executif),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute(select(PosteModule)) result = await db.execute(
select(PosteModule)
.join(Poste, PosteModule.poste_id == Poste.id)
.where(Poste.groupe_id == executif.groupe_id)
)
modules = result.scalars().all() modules = result.scalars().all()
par_poste: dict[str, list[str]] = {} par_poste: dict[str, list[str]] = {}
for m in modules: for m in modules:
@ -725,7 +752,8 @@ async def definir_modules_poste(
executif=Depends(require_executif), executif=Depends(require_executif),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
poste = await db.get(Poste, poste_id) result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
poste = result.scalar_one_or_none()
if not poste or not poste.actif: if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")

View file

@ -36,7 +36,12 @@ def _date_civile_reunion(valeur):
async def _postes_vacants(groupe_id, db: AsyncSession): async def _postes_vacants(groupe_id, db: AsyncSession):
result = await db.execute(select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage)) result = await db.execute(
select(Poste).where(
Poste.groupe_id == groupe_id,
Poste.actif == True,
).order_by(Poste.ordre_affichage)
)
postes = result.scalars().all() postes = result.scalars().all()
vacants = [] vacants = []
for poste in postes: for poste in postes:

View file

@ -17,6 +17,10 @@ router = APIRouter(prefix="/rotations", tags=["G5 — Rotations"])
async def _to_out(r: Rotation, db: AsyncSession) -> RotationOut: async def _to_out(r: Rotation, db: AsyncSession) -> RotationOut:
poste = await db.get(Poste, r.poste_id) poste = await db.get(Poste, r.poste_id)
mbr = await db.get(Membre, r.membre_id) mbr = await db.get(Membre, r.membre_id)
if poste and poste.groupe_id != r.groupe_id:
poste = None
if mbr and mbr.groupe_id != r.groupe_id:
mbr = None
return RotationOut( return RotationOut(
id=r.id, id=r.id,
date_reunion=r.date_reunion, date_reunion=r.date_reunion,
@ -72,6 +76,12 @@ async def creer_rotation(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""G5 — Assigner un serviteur pour une date de réunion.""" """G5 — Assigner un serviteur pour une date de réunion."""
poste = await db.get(Poste, req.poste_id)
mbr = await db.get(Membre, req.membre_id)
if not poste or poste.groupe_id != executif.groupe_id or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
if not mbr or mbr.groupe_id != executif.groupe_id or not mbr.actif:
raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
rotation = Rotation( rotation = Rotation(
groupe_id=executif.groupe_id, groupe_id=executif.groupe_id,
date_reunion=req.date_reunion, date_reunion=req.date_reunion,
@ -94,6 +104,12 @@ async def creer_rotations_batch(
"""G5 — Assigner plusieurs serviteurs d'un coup pour une semaine.""" """G5 — Assigner plusieurs serviteurs d'un coup pour une semaine."""
created = [] created = []
for req in rotations: for req in rotations:
poste = await db.get(Poste, req.poste_id)
mbr = await db.get(Membre, req.membre_id)
if not poste or poste.groupe_id != executif.groupe_id or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
if not mbr or mbr.groupe_id != executif.groupe_id or not mbr.actif:
raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
rotation = Rotation( rotation = Rotation(
groupe_id=executif.groupe_id, groupe_id=executif.groupe_id,
date_reunion=req.date_reunion, date_reunion=req.date_reunion,

View file

@ -107,7 +107,7 @@ async def initialiser_demo(db: AsyncSession) -> dict:
await remettre_a_zero(db) await remettre_a_zero(db)
groupe = await _premier_groupe(db) groupe = await _premier_groupe(db)
membres = await _membres_par_courriel(db) membres = await _membres_par_courriel(db)
postes = await _postes_par_nom(db) postes = await _postes_par_nom(db, groupe.id)
litterature = await _litterature(db) litterature = await _litterature(db)
await _ajouter_membres_demo(db, groupe, membres, postes) await _ajouter_membres_demo(db, groupe, membres, postes)
@ -140,6 +140,7 @@ async def _creer_base(db: AsyncSession):
postes = {} postes = {}
for nom, categorie, description, prerequis, ordre, modules in POSTES: for nom, categorie, description, prerequis, ordre, modules in POSTES:
poste = Poste( poste = Poste(
groupe_id=groupe.id,
nom=nom, nom=nom,
categorie=categorie, categorie=categorie,
description=description, description=description,
@ -438,10 +439,10 @@ async def _membres_par_courriel(db: AsyncSession) -> dict[str, Membre]:
return {m.courriel: m for m in result.scalars().all()} return {m.courriel: m for m in result.scalars().all()}
async def _postes_par_nom(db: AsyncSession) -> dict[str, Poste]: async def _postes_par_nom(db: AsyncSession, groupe_id) -> dict[str, Poste]:
from sqlalchemy import select from sqlalchemy import select
result = await db.execute(select(Poste)) result = await db.execute(select(Poste).where(Poste.groupe_id == groupe_id))
return {p.nom: p for p in result.scalars().all()} return {p.nom: p for p in result.scalars().all()}

View file

@ -8,6 +8,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.decision_types import type_decision_depuis_proposition from app.core.decision_types import type_decision_depuis_proposition
from app.core.financial_position import PositionTresorerie from app.core.financial_position import PositionTresorerie
from app.core.sanitization import safe_query_params from app.core.sanitization import safe_query_params
from app.models.poste import Poste
class DecisionTypeTests(unittest.TestCase): class DecisionTypeTests(unittest.TestCase):
@ -46,5 +47,11 @@ class AuditTests(unittest.TestCase):
self.assertEqual(safe_query_params(request.query_params), {"pin": "***", "membre_id": "abc"}) self.assertEqual(safe_query_params(request.query_params), {"pin": "***", "membre_id": "abc"})
class MultiTenantTests(unittest.TestCase):
def test_postes_are_group_scoped(self):
self.assertIn("groupe_id", Poste.__table__.columns)
self.assertFalse(Poste.__table__.columns["groupe_id"].nullable)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -111,6 +111,7 @@ async def seed():
postes_map = {} postes_map = {}
for nom, cat, desc, duree, prereq, ordre in POSTES: for nom, cat, desc, duree, prereq, ordre in POSTES:
poste = Poste( poste = Poste(
groupe_id=groupe.id,
nom=nom, nom=nom,
categorie=cat, categorie=cat,
description=desc, description=desc,

23
docs/MULTI_TENANCY.md Normal file
View file

@ -0,0 +1,23 @@
# Isolation multi-groupe
L'application est servie sous une URL commune, mais les donnees d'un groupe doivent rester isolees de celles des autres groupes. Le contexte actif est toujours le `groupe_id` du membre authentifie, transporte dans le jeton de session et verifie au chargement du membre.
## Regles d'isolation
- Une requete authentifiee ne doit lire ou modifier que les donnees du groupe du membre courant.
- Les postes, affectations, candidatures, rotations, reunions, assemblees, propositions, transactions, reserves et journaux sont des donnees de groupe.
- Les permissions de modules sont derivees des postes du groupe courant seulement.
- Le calendrier affiche uniquement les serviteurs du groupe courant. Il ne fait pas de lecture district ou multi-groupe.
- Les liens invites donnent acces seulement a l'assemblee ciblee par l'invitation et a ses donnees publiques.
- Les tables sans `groupe_id` direct doivent etre bornees par leur parent : par exemple une presence est bornee par sa reunion, un PV par sa reunion, une permission de module par son poste.
## Points de vigilance
- Une route qui accepte un identifiant fourni par le client doit verifier que l'objet appartient au groupe courant avant de l'utiliser.
- Une jointure de permissions doit filtrer a la fois l'affectation et le poste sur le groupe courant.
- Une initialisation ou remise a zero par Sysadmin est une operation d'instance. Elle ne doit pas etre exposee comme operation courante en production multi-groupe.
- Toute nouvelle table metier doit recevoir un `groupe_id`, sauf si son rattachement a une table parent isolee est strict et documente.
## Etat actuel
Les postes sont maintenant scopes par `groupe_id`, ce qui evite qu'un groupe modifie les definitions de postes ou les permissions d'un autre groupe. Les routes de calendrier, de postes, d'assemblees et de rotations filtrent les donnees par groupe courant.

View file

@ -75,6 +75,8 @@ la regle est formulee selon le code et l'ecart est liste dans la section
- RM-POS-019 - Terminer un mandat met l'affectation au statut `termine` et conserve la date de fin, la raison et le membre qui l'a terminee. - RM-POS-019 - Terminer un mandat met l'affectation au statut `termine` et conserve la date de fin, la raison et le membre qui l'a terminee.
- RM-POS-020 - Les modules consultables par un membre dependent des permissions associees a ses postes, avec acces complet pour `Sysadmin`. - RM-POS-020 - Les modules consultables par un membre dependent des permissions associees a ses postes, avec acces complet pour `Sysadmin`.
- RM-POS-021 - Les permissions de modules accordees aux postes sont reconnues par les verifications API des responsabilites specialisees. - RM-POS-021 - Les permissions de modules accordees aux postes sont reconnues par les verifications API des responsabilites specialisees.
- RM-POS-022 - Les postes et leurs permissions appartiennent a un seul groupe.
- RM-POS-023 - Un poste d'un groupe ne peut jamais accorder un acces, une affectation ou une candidature dans un autre groupe.
## Reunions et dates ## Reunions et dates