Separer le sysadmin instance des groupes
This commit is contained in:
parent
7ed9dd9167
commit
bcc2552c44
8 changed files with 102 additions and 28 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
30
backend/app/models/instance_admin.py
Normal file
30
backend/app/models/instance_admin.py
Normal 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
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue