Separer le mode sysadmin instance

This commit is contained in:
Daniel Allaire 2026-06-01 06:45:47 -04:00
parent ea01dcec00
commit 8fafb8e000
6 changed files with 57 additions and 11 deletions

View file

@ -21,10 +21,12 @@ def verify_pin(pin: str, hashed: str) -> bool:
return bcrypt.checkpw(pin.encode("utf-8"), hashed.encode("utf-8"))
def create_token(membre_id: str, groupe_id: str, 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) -> str:
jours = expire_jours or settings.JWT_EXPIRE_DAYS
expire = datetime.now(timezone.utc) + timedelta(days=jours)
payload = {"sub": str(membre_id), "grp": str(groupe_id), "exp": expire}
payload = {"sub": str(membre_id), "exp": expire}
if groupe_id:
payload["grp"] = str(groupe_id)
if admin_id:
payload["adm"] = str(admin_id)
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)

View file

@ -18,7 +18,6 @@ from app.models.groupe import Groupe
from app.models.membre import Membre
from app.models.reunion import Reunion
from app.models.historique_modification import HistoriqueModification
from app.schemas.schemas import MembrePublic, TokenResponse
router = APIRouter(prefix="/admin", tags=["Administration"])
@ -44,13 +43,25 @@ async def require_sysadmin(membre=Depends(get_current_membre)):
return membre
@router.post("/sysadmin-login", response_model=TokenResponse)
@router.post("/sysadmin-login")
async def sysadmin_login(req: SysadminLoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Membre).where(Membre.courriel == "sysadmin", Membre.actif == True))
for membre in result.scalars().all():
if verify_pin(req.pin, membre.pin_hash):
token = create_token(str(membre.id), str(membre.groupe_id), expire_jours=1)
return TokenResponse(access_token=token, membre=MembrePublic.model_validate(membre))
token = create_token(str(membre.id), None, expire_jours=1)
return {
"access_token": token,
"token_type": "bearer",
"sysadmin_instance": True,
"membre": {
"id": str(membre.id),
"prenom": "Sysadmin",
"courriel": "sysadmin",
"groupe_id": None,
"date_abstinence": None,
"is_sysadmin": True,
},
}
raise HTTPException(status_code=401, detail="PIN Sysadmin invalide")

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.financial_position import PositionTresorerie
from app.core.sanitization import safe_query_params
from app.core.security import create_token, decode_token
from app.models.poste import Poste
from app.models.affectation import Affectation
from app.models.candidature import Candidature
@ -60,6 +61,14 @@ class AuditTests(unittest.TestCase):
self.assertEqual(safe_query_params(request.query_params), {"pin": "***", "membre_id": "abc"})
class SecurityTokenTests(unittest.TestCase):
def test_sysadmin_instance_token_has_no_group_context(self):
token = create_token("member-id", None, expire_jours=1)
payload = decode_token(token)
self.assertEqual(payload["sub"], "member-id")
self.assertNotIn("grp", payload)
class MultiTenantTests(unittest.TestCase):
def test_postes_are_group_scoped(self):
self.assertIn("groupe_id", Poste.__table__.columns)

View file

@ -36,14 +36,16 @@ import ModePapier from './pages/ModePapier';
import BottomNav from './components/BottomNav';
export default function App() {
const { token, page, navContext, loadAccueil, loadGroupes } = useStore();
const { token, page, navContext, sysadminInstance, loadAccueil, loadGroupes } = useStore();
const chemin = window.location.pathname;
useEffect(() => {
const handleUnauthorized = () => useStore.getState().logout();
window.addEventListener('gm:unauthorized', handleUnauthorized);
loadGroupes();
if (token) {
if (token && sysadminInstance) {
useStore.setState({ page: 'G14' });
} else if (token) {
loadAccueil().then(() => {
// Restaurer la page sauvegardée ou aller à l'accueil
const savedPage = localStorage.getItem('gm_page');
@ -62,6 +64,9 @@ export default function App() {
// Pages protégées
if (token) {
if (sysadminInstance) {
return <main><GestionGroupes /></main>;
}
let pageActive;
switch (page) {
case 'accueil':

View file

@ -8,7 +8,7 @@ const jourLabel = (jour) => {
};
export default function Login() {
const { login, loadAccueil, loading, error, groupes, loadGroupes, setPage } = useStore();
const { login, loading, error, groupes, loadGroupes, setPage } = useStore();
const [courriel, setCourriel] = useState(localStorage.getItem('gm_courriel') || '');
const [pin, setPin] = useState('');
const [groupeId, setGroupeId] = useState(localStorage.getItem('gm_groupe_id') || '');
@ -54,13 +54,22 @@ export default function Login() {
try {
const data = await api.post('/admin/sysadmin-login', { pin });
api.setToken(data.access_token, false);
localStorage.setItem('gm_sysadmin_instance', '1');
localStorage.removeItem('gm_groupe_id');
useStore.setState({
token: data.access_token,
membre: data.membre,
groupeId: data.membre.groupe_id,
groupeId: null,
groupe: null,
postes: ['Sysadmin'],
estExecutif: true,
estSysadmin: true,
modulesConsultation: [],
modulesSaisie: [],
modulesGestion: [],
sysadminInstance: true,
error: null,
});
await loadAccueil();
setPage('G14');
} catch (e) {
useStore.setState({ error: e.message });

View file

@ -6,6 +6,7 @@ export const useStore = create((set, get) => ({
token: api.getToken(),
membre: null,
groupeId: localStorage.getItem('gm_groupe_id'),
sysadminInstance: localStorage.getItem('gm_sysadmin_instance') === '1',
groupes: [],
// Accueil
@ -49,6 +50,7 @@ export const useStore = create((set, get) => ({
courriel, pin, groupe_id: groupeId, se_souvenir: seSouvenir,
});
api.setToken(data.access_token, seSouvenir);
localStorage.removeItem('gm_sysadmin_instance');
localStorage.setItem('gm_groupe_id', groupeId);
if (seSouvenir) {
localStorage.setItem('gm_courriel', courriel);
@ -59,6 +61,7 @@ export const useStore = create((set, get) => ({
token: data.access_token,
membre: data.membre,
groupeId,
sysadminInstance: false,
loading: false,
});
await get().loadAccueil();
@ -79,10 +82,12 @@ export const useStore = create((set, get) => ({
};
const res = await api.post('/auth/inscription', payload);
api.setToken(res.access_token, false);
localStorage.removeItem('gm_sysadmin_instance');
localStorage.setItem('gm_groupe_id', data.groupe_id || '');
set({
token: res.access_token,
membre: res.membre,
sysadminInstance: false,
loading: false,
});
await get().loadAccueil();
@ -98,9 +103,12 @@ export const useStore = create((set, get) => ({
try {
const data = await api.post(`/auth/impersoner/${membreId}`);
api.setToken(data.access_token, false);
localStorage.removeItem('gm_sysadmin_instance');
set({
token: data.access_token,
membre: data.membre,
groupeId: data.membre.groupe_id,
sysadminInstance: false,
loading: false,
});
await get().loadAccueil();
@ -132,6 +140,7 @@ export const useStore = create((set, get) => ({
logout: () => {
api.setToken(null);
localStorage.removeItem('gm_groupe_id');
localStorage.removeItem('gm_sysadmin_instance');
localStorage.removeItem('gm_page');
localStorage.removeItem('gm_nav_context');
set({
@ -139,6 +148,7 @@ export const useStore = create((set, get) => ({
membre: null,
groupe: null,
groupeId: null,
sysadminInstance: false,
postes: [],
estExecutif: false,
estSysadmin: false,