Separer le sysadmin instance des groupes

This commit is contained in:
Daniel Allaire 2026-06-01 07:22:01 -04:00
parent 7ed9dd9167
commit bcc2552c44
8 changed files with 102 additions and 28 deletions

View file

@ -21,10 +21,18 @@ def verify_pin(pin: str, hashed: str) -> bool:
return bcrypt.checkpw(pin.encode("utf-8"), hashed.encode("utf-8")) return bcrypt.checkpw(pin.encode("utf-8"), hashed.encode("utf-8"))
def create_token(membre_id: str, groupe_id: str | None, expire_jours: int = None, admin_id: str | None = None) -> str: def create_token(
membre_id: str,
groupe_id: str | None,
expire_jours: int = None,
admin_id: str | None = None,
subject_type: str = "membre",
) -> str:
jours = expire_jours or settings.JWT_EXPIRE_DAYS jours = expire_jours or settings.JWT_EXPIRE_DAYS
expire = datetime.now(timezone.utc) + timedelta(days=jours) expire = datetime.now(timezone.utc) + timedelta(days=jours)
payload = {"sub": str(membre_id), "exp": expire} payload = {"sub": str(membre_id), "exp": expire}
if subject_type != "membre":
payload["typ"] = subject_type
if groupe_id: if groupe_id:
payload["grp"] = str(groupe_id) payload["grp"] = str(groupe_id)
if admin_id: if admin_id:
@ -48,11 +56,18 @@ async def get_current_membre(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
from app.models.membre import Membre from app.models.membre import Membre
from app.models.instance_admin import InstanceAdmin
payload = decode_token(credentials.credentials) payload = decode_token(credentials.credentials)
membre_id = payload.get("sub") membre_id = payload.get("sub")
groupe_id = payload.get("grp") groupe_id = payload.get("grp")
if not membre_id: if not membre_id:
raise HTTPException(status_code=401, detail="Token invalide") raise HTTPException(status_code=401, detail="Token invalide")
if payload.get("typ") == "instance_admin":
result = await db.execute(select(InstanceAdmin).where(InstanceAdmin.id == membre_id, InstanceAdmin.actif == True))
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Sysadmin d'instance introuvable ou inactif")
return admin
result = await db.execute(select(Membre).where(Membre.id == membre_id, Membre.actif == True)) result = await db.execute(select(Membre).where(Membre.id == membre_id, Membre.actif == True))
membre = result.scalar_one_or_none() membre = result.scalar_one_or_none()
if not membre: if not membre:

View file

@ -29,6 +29,7 @@ from app.models.destinataire_contribution import DestinataireContribution # noq
from app.models.rapport_adopte import RapportAdopte # noqa: F401 from app.models.rapport_adopte import RapportAdopte # noqa: F401
from app.models.vente_jeton import VenteJeton # noqa: F401 from app.models.vente_jeton import VenteJeton # noqa: F401
from app.models.historique_modification import HistoriqueModification # noqa: F401 from app.models.historique_modification import HistoriqueModification # noqa: F401
from app.models.instance_admin import InstanceAdmin # noqa: F401
@asynccontextmanager @asynccontextmanager

View file

@ -0,0 +1,30 @@
"""Administrateurs globaux de l'instance."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class InstanceAdmin(Base):
__tablename__ = "instance_admins"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
identifiant: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
prenom: Mapped[str] = mapped_column(String(50), nullable=False, default="Sysadmin")
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
actif: Mapped[bool] = mapped_column(Boolean, default=True)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@property
def courriel(self) -> str:
return self.identifiant
@property
def groupe_id(self):
return None
@property
def is_sysadmin(self) -> bool:
return True

View file

@ -7,6 +7,7 @@ from pydantic import BaseModel
from app.core.database import get_db from app.core.database import get_db
from app.core.security import create_token, get_current_membre, is_sysadmin, verify_pin from app.core.security import create_token, get_current_membre, is_sysadmin, verify_pin
from app.services.admin_database import ( from app.services.admin_database import (
assurer_sysadmin_instance,
creer_groupe, creer_groupe,
initialiser_demo, initialiser_demo,
modifier_groupe, modifier_groupe,
@ -45,23 +46,22 @@ async def require_sysadmin(membre=Depends(get_current_membre)):
@router.post("/sysadmin-login") @router.post("/sysadmin-login")
async def sysadmin_login(req: SysadminLoginRequest, db: AsyncSession = Depends(get_db)): async def sysadmin_login(req: SysadminLoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Membre).where(Membre.courriel == "sysadmin", Membre.actif == True)) admin = await assurer_sysadmin_instance(db)
for membre in result.scalars().all(): if admin.actif and verify_pin(req.pin, admin.pin_hash):
if verify_pin(req.pin, membre.pin_hash): token = create_token(str(admin.id), None, expire_jours=1, subject_type="instance_admin")
token = create_token(str(membre.id), None, expire_jours=1) return {
return { "access_token": token,
"access_token": token, "token_type": "bearer",
"token_type": "bearer", "sysadmin_instance": True,
"sysadmin_instance": True, "membre": {
"membre": { "id": str(admin.id),
"id": str(membre.id), "prenom": admin.prenom,
"prenom": "Sysadmin", "courriel": admin.identifiant,
"courriel": "sysadmin", "groupe_id": None,
"groupe_id": None, "date_abstinence": None,
"date_abstinence": None, "is_sysadmin": True,
"is_sysadmin": True, },
}, }
}
raise HTTPException(status_code=401, detail="PIN Sysadmin invalide") raise HTTPException(status_code=401, detail="PIN Sysadmin invalide")

View file

@ -20,6 +20,7 @@ from app.models.envoi_contribution import EnvoiContribution
from app.models.evenement import Evenement from app.models.evenement import Evenement
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.historique_modification import HistoriqueModification from app.models.historique_modification import HistoriqueModification
from app.models.instance_admin import InstanceAdmin
from app.models.jeton import Jeton from app.models.jeton import Jeton
from app.models.journal_action import JournalAction from app.models.journal_action import JournalAction
from app.models.litterature import Litterature from app.models.litterature import Litterature
@ -42,6 +43,7 @@ from app.models.vente_jeton import VenteJeton
DEMO_GROUP_NAME = "Groupe Démonstration" DEMO_GROUP_NAME = "Groupe Démonstration"
OLD_DEMO_GROUP_NAMES = ("Groupe Résilience",) OLD_DEMO_GROUP_NAMES = ("Groupe Résilience",)
INSTANCE_SYSADMIN_IDENTIFIANT = "sysadmin"
POSTES = [ POSTES = [
@ -73,6 +75,24 @@ MEMBRES_DEMO = [
] ]
async def assurer_sysadmin_instance(db: AsyncSession) -> InstanceAdmin:
result = await db.execute(
select(InstanceAdmin).where(InstanceAdmin.identifiant == INSTANCE_SYSADMIN_IDENTIFIANT)
)
admin = result.scalar_one_or_none()
if admin:
return admin
admin = InstanceAdmin(
identifiant=INSTANCE_SYSADMIN_IDENTIFIANT,
prenom="Sysadmin",
pin_hash=hash_pin("0000"),
actif=True,
)
db.add(admin)
await db.flush()
return admin
def _catalogue_litterature() -> list[dict]: def _catalogue_litterature() -> list[dict]:
chemin = Path(__file__).resolve().parents[3] / "data" / "import" / "Liste-de-prix-rev.-Fevrier-2026.csv" chemin = Path(__file__).resolve().parents[3] / "data" / "import" / "Liste-de-prix-rev.-Fevrier-2026.csv"
if chemin.exists(): if chemin.exists():

View file

@ -16,6 +16,7 @@ from app.models.collecte import Collecte
from app.models.depense import Depense from app.models.depense import Depense
from app.models.evenement import Evenement from app.models.evenement import Evenement
from app.models.jeton import Jeton from app.models.jeton import Jeton
from app.models.instance_admin import InstanceAdmin
from app.models.litterature import Litterature from app.models.litterature import Litterature
from app.models.presence import Presence from app.models.presence import Presence
from app.models.proposition import Proposition from app.models.proposition import Proposition
@ -63,9 +64,10 @@ class AuditTests(unittest.TestCase):
class SecurityTokenTests(unittest.TestCase): class SecurityTokenTests(unittest.TestCase):
def test_sysadmin_instance_token_has_no_group_context(self): def test_sysadmin_instance_token_has_no_group_context(self):
token = create_token("member-id", None, expire_jours=1) token = create_token("admin-id", None, expire_jours=1, subject_type="instance_admin")
payload = decode_token(token) payload = decode_token(token)
self.assertEqual(payload["sub"], "member-id") self.assertEqual(payload["sub"], "admin-id")
self.assertEqual(payload["typ"], "instance_admin")
self.assertNotIn("grp", payload) self.assertNotIn("grp", payload)
@ -74,6 +76,10 @@ class MultiTenantTests(unittest.TestCase):
self.assertIn("groupe_id", Poste.__table__.columns) self.assertIn("groupe_id", Poste.__table__.columns)
self.assertFalse(Poste.__table__.columns["groupe_id"].nullable) self.assertFalse(Poste.__table__.columns["groupe_id"].nullable)
def test_instance_admin_is_not_group_scoped(self):
self.assertNotIn("groupe_id", InstanceAdmin.__table__.columns)
self.assertTrue(InstanceAdmin.__table__.columns["identifiant"].unique)
def test_core_business_tables_are_group_scoped(self): def test_core_business_tables_are_group_scoped(self):
for model in [ for model in [
Affectation, Affectation,

View file

@ -11,7 +11,7 @@ import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from app.core.database import engine, Base, async_session from app.core.database import engine, Base, async_session
from app.services.admin_database import initialiser_demo from app.services.admin_database import assurer_sysadmin_instance, initialiser_demo
# Importer TOUS les modèles pour que create_all crée toutes les tables # Importer TOUS les modèles pour que create_all crée toutes les tables
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.membre import Membre from app.models.membre import Membre
@ -40,6 +40,7 @@ from app.models.reserve import Reserve
from app.models.transaction_comptable import TransactionComptable from app.models.transaction_comptable import TransactionComptable
from app.models.mouvement_reserve import MouvementReserve from app.models.mouvement_reserve import MouvementReserve
from app.models.destinataire_contribution import DestinataireContribution from app.models.destinataire_contribution import DestinataireContribution
from app.models.instance_admin import InstanceAdmin
async def seed(): async def seed():
@ -47,6 +48,7 @@ async def seed():
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async with async_session() as db: async with async_session() as db:
await assurer_sysadmin_instance(db)
demo = await initialiser_demo(db) demo = await initialiser_demo(db)
await db.commit() await db.commit()

View file

@ -84,13 +84,6 @@ export default function Login() {
<p className="text-blue-200 text-sm mt-1">District 87-16</p> <p className="text-blue-200 text-sm mt-1">District 87-16</p>
</div> </div>
<a
href="/presentation.html"
className="mb-4 block w-full rounded-xl bg-white/95 p-4 text-center text-sm font-bold text-aa-blue shadow-lg active:scale-[0.98]"
>
Découvrir l'application
</a>
<div className="flex-1 space-y-3 pb-6"> <div className="flex-1 space-y-3 pb-6">
{groupes.length === 0 ? ( {groupes.length === 0 ? (
<div className="rounded-2xl bg-white/10 p-5 text-center text-blue-100"> <div className="rounded-2xl bg-white/10 p-5 text-center text-blue-100">
@ -187,6 +180,13 @@ export default function Login() {
})} })}
</div> </div>
<a
href="/presentation.html"
className="mb-4 block w-full rounded-xl bg-white/95 p-4 text-center text-sm font-bold text-aa-blue shadow-lg active:scale-[0.98]"
>
Découvrir l'application
</a>
<button <button
type="button" type="button"
onClick={ouvrirSysadmin} onClick={ouvrirSysadmin}