2026-08-14 20:53:03 +02:00
""" Contrôle d ' accès centralisé de l ' application.
La politique s ' applique à toutes les routes enregistrées. Les décorateurs locaux
restent utiles pour documenter les cas particuliers , mais une route oubliée ne
retombe plus sur le simple fait d ' être connecté.
"""
from functools import wraps
2026-08-21 18:46:19 +02:00
from flask import abort , request , redirect , url_for , jsonify , current_app
2026-08-14 20:53:03 +02:00
from flask_login import current_user
ROLE_ALIASES = {
2026-08-21 20:10:29 +02:00
# Alias historique conservé uniquement pour l'affichage/compatibilité.
# La sécurité utilise le slug RBAC réel ``super_admin``.
2026-08-14 20:53:03 +02:00
" super_admin " : " admin " ,
" admin " : " admin " ,
" chef " : " responsable_gmao " ,
" responsable " : " responsable_gmao " ,
" responsable_gmao " : " responsable_gmao " ,
" tech " : " technicien " ,
" technician " : " technicien " ,
" technicien " : " technicien " ,
" assistant_prevention " : " assistant_prevention " ,
" requester " : " demandeur " ,
" user " : " demandeur " ,
" demandeur " : " demandeur " ,
" viewer " : " lecture " ,
" lecture " : " lecture " ,
}
ROLE_LABELS = {
2026-08-21 20:10:29 +02:00
" super_admin " : " Super administrateur " ,
" admin " : " Administrateur " ,
2026-08-14 20:53:03 +02:00
" responsable_gmao " : " Responsable GMAO " ,
" technicien " : " Technicien " ,
" assistant_prevention " : " Assistant de prévention " ,
" demandeur " : " Demandeur " ,
" lecture " : " Lecture seule " ,
}
2026-08-21 18:46:19 +02:00
# LEGACY_SEED_ONLY : utilisé uniquement par les migrations historiques et le
# bootstrap des tests. Le runtime n'en fait jamais un fallback d'autorisation.
2026-08-14 20:53:03 +02:00
PERMISSIONS_BY_ROLE = {
2026-08-21 20:10:29 +02:00
# LEGACY_SEED_ONLY : le rôle admin est désormais un rôle explicite. Le
# wildcard appartient exclusivement à super_admin (voir migration).
" super_admin " : { " * " } ,
" admin " : {
" dashboard.view " , " intervention.view " , " intervention.create " , " intervention.edit " ,
" intervention.assign " , " intervention.change_status " , " intervention.close " , " intervention.reject " ,
" intervention.delete " , " intervention.postpone " , " intervention.comment " ,
" patrimoine.view " , " patrimoine.create " , " patrimoine.edit " , " patrimoine.move " ,
" patrimoine.change_quantity " , " patrimoine.retire " , " patrimoine.delete " ,
" planning.view " , " planning.create " , " planning.edit " , " planning.delete " , " planning.manage " ,
" stock.view " , " stock.edit " , " stock.receive " , " stock.issue " , " stock.transfer " , " stock.inventory " ,
" stock.adjust " , " stock.destroy " , " stock.transvasement " , " stock.dilute " , " stock.configure " ,
" contract.view " , " contract.manage " , " prevention.view " , " prevention.manage " , " export.use " ,
" housing.private " , " gmao_config.view " , " gmao_config.configure " , " watchdog_dnd.view " ,
" watchdog_dnd.configure " , " integration.ent.view " , " integration.ent.configure " ,
" integration.outlook.view " , " integration.outlook.configure " , " integration.pronote.view " ,
" integration.pronote.configure " , " integration.yeastar.view " , " integration.yeastar.configure " ,
" user.view " , " user.create " , " user.edit " , " user.delete " , " user.manage " , " role.view " ,
" role.create " , " role.edit " , " role.archive " , " role.manage " , " audit.view " , " audit.export " ,
" system.view " , " system.configure " , " system.admin " ,
} ,
2026-08-14 20:53:03 +02:00
" responsable_gmao " : {
" dashboard.view " , " intervention.view " , " intervention.create " , " intervention.manage " ,
" patrimoine.view " , " patrimoine.manage " , " planning.view " , " planning.manage " ,
" stock.view " , " stock.manage " , " contract.view " , " contract.manage " ,
" prevention.view " , " prevention.manage " , " export.use " ,
2026-08-15 01:12:51 +02:00
" housing.private " ,
2026-08-14 20:53:03 +02:00
} ,
" technicien " : {
" dashboard.view " , " intervention.view " , " intervention.create " , " intervention.manage " ,
" patrimoine.view " , " patrimoine.manage " , " planning.view " , " planning.manage " ,
" stock.view " , " stock.manage " , " contract.view " , " prevention.view " , " export.use " ,
} ,
" assistant_prevention " : {
" dashboard.view " , " intervention.view " , " intervention.create " , " patrimoine.view " ,
" planning.view " , " prevention.view " , " prevention.manage " , " export.use " ,
} ,
" demandeur " : { " dashboard.view " , " intervention.view " , " intervention.create " } ,
" lecture " : { " dashboard.view " , " intervention.view " , " patrimoine.view " , " planning.view " , " contract.view " } ,
}
PUBLIC_ENDPOINTS = {
" auth.login " , " health.health " , " static " ,
" setup_wizard.index " , " setup_wizard.api_progress " , " setup_wizard.api_save_step " ,
" setup_wizard.api_complete " , " setup_wizard.api_test_ent " , " setup_wizard.api_test_pronote " ,
}
ADMIN_BLUEPRINTS = {
" admin " , " ai_config " , " gmao_config " , " logs " , " status " , " yeastar " ,
" outlook_auth " , " outlook_dashboard " , " outlook_pages " , " outlook_sync " , " ent " , " pronote " ,
}
PATRIMOINE_BLUEPRINTS = {
2026-08-14 23:23:33 +02:00
" equipments " , " equipments_meters " , " equipments_documents " , " equipments_restrictions " , " housing " ,
2026-08-14 20:53:03 +02:00
" equipments_scheduled " , " buildings " , " zones " , " rooms " , " room_types " , " wizard " , " lots " ,
}
PLANNING_BLUEPRINTS = { " planning " , " scheduler " , " interventions_planning " }
2026-08-21 16:13:00 +02:00
STOCK_BLUEPRINTS = { " parts " , " meters " , " cleaning " }
2026-08-14 20:53:03 +02:00
CONTRACT_BLUEPRINTS = { " companies " , " contracts " , " services " }
2026-08-14 23:43:13 +02:00
PREVENTION_BLUEPRINTS = { " trainings " , " constraints " , " prevention " }
2026-08-14 20:53:03 +02:00
2026-08-21 20:10:29 +02:00
# Catalogue réellement utilisé par les routes et les fonctions métier.
2026-08-21 17:22:47 +02:00
GRANULAR_PERMISSION_CODES = {
2026-08-21 20:10:29 +02:00
code for codes in PERMISSIONS_BY_ROLE . values ( ) for code in codes if code != " * "
} | {
2026-08-21 17:48:59 +02:00
" gmao_config.view " , " gmao_config.configure " ,
" watchdog_dnd.view " , " watchdog_dnd.configure " ,
" integration.ent.view " , " integration.ent.configure " ,
" integration.outlook.view " , " integration.outlook.configure " ,
" integration.pronote.view " , " integration.pronote.configure " ,
" integration.yeastar.view " , " integration.yeastar.configure " ,
" user.view " , " user.create " , " user.edit " , " user.delete " , " user.manage " ,
" role.view " , " role.create " , " role.edit " , " role.archive " , " role.manage " ,
" audit.view " , " audit.export " , " system.view " , " system.configure " ,
}
PERMISSION_LABELS = {
" gmao_config " : " Configuration GMAO " ,
" watchdog_dnd " : " Watchdog DND " ,
" integration.ent " : " Intégration ENT " ,
" integration.outlook " : " Intégration Outlook " ,
" integration.pronote " : " Intégration Pronote " ,
" integration.yeastar " : " Intégration Yeastar " ,
" user " : " Utilisateurs " ,
" role " : " Rôles et permissions " ,
" audit " : " Journaux d ' audit " ,
" system " : " Système " ,
}
2026-08-21 17:22:47 +02:00
2026-08-21 20:10:29 +02:00
ACTION_LABELS = {
" view " : ( " Consulter " , " Permet de consulter les informations du domaine. " ) ,
" create " : ( " Créer " , " Permet d ' enregistrer de nouvelles informations dans le domaine. " ) ,
" edit " : ( " Modifier " , " Permet de modifier les informations existantes du domaine. " ) ,
" delete " : ( " Supprimer " , " Permet de supprimer ou retirer les éléments concernés. " ) ,
" manage " : ( " Administrer " , " Permet d ' administrer les éléments et paramètres du domaine. " ) ,
" configure " : ( " Configurer " , " Permet de modifier la configuration du domaine. " ) ,
" export " : ( " Exporter " , " Permet d ' exporter les informations du domaine. " ) ,
" validate " : ( " Valider " , " Permet de valider l ' opération concernée. " ) ,
" reject " : ( " Refuser " , " Permet de refuser l ' opération concernée. " ) ,
" assign " : ( " Affecter " , " Permet d ' affecter l ' opération à une personne ou une entreprise. " ) ,
" change_status " : ( " Changer le statut " , " Permet de modifier le statut de l ' opération. " ) ,
" close " : ( " Clôturer " , " Permet de clôturer l ' opération et d ' enregistrer son état final. " ) ,
" move " : ( " Déplacer " , " Permet de déplacer les éléments concernés entre les emplacements. " ) ,
" receive " : ( " Réceptionner " , " Permet d ' enregistrer les réceptions et l ' entrée en stock. " ) ,
" issue " : ( " Sortir du stock " , " Permet d ' enregistrer une sortie de stock, notamment vers un agent. " ) ,
" transfer " : ( " Transférer " , " Permet de transférer une quantité entre emplacements. " ) ,
" inventory " : ( " Réaliser un inventaire " , " Permet d ' enregistrer un comptage physique et ses écarts. " ) ,
" adjust " : ( " Corriger un stock " , " Permet d ' enregistrer une correction de stock tracée. " ) ,
" destroy " : ( " Détruire " , " Permet d ' enregistrer la destruction de produits. " ) ,
" transvasement " : ( " Transvaser " , " Permet d ' enregistrer un transvasement de produit. " ) ,
" dilute " : ( " Préparer une dilution " , " Permet d ' enregistrer la préparation d ' un produit dilué. " ) ,
" archive " : ( " Archiver " , " Permet d ' archiver le domaine concerné sans effacer son historique. " ) ,
" private " : ( " Consulter les données privées " , " Permet de consulter les données personnelles protégées. " ) ,
" all " : ( " Accès complet " , " Accès complet à l ' application, réservé au super administrateur. " ) ,
}
def permission_metadata ( code ) :
""" Retourne un libellé et une description compréhensibles pour l ' UI. """
if code == " * " :
return " Accès complet à l ' application " , ACTION_LABELS [ " all " ] [ 1 ]
module , _ , action = code . partition ( " . " )
domain = PERMISSION_LABELS . get ( module , module . replace ( " _ " , " " ) . capitalize ( ) )
label , description = ACTION_LABELS . get ( action , ( action . replace ( " _ " , " " ) . capitalize ( ) , f " Permet d ' effectuer l ' action « { action . replace ( ' _ ' , ' ' ) } » dans le domaine { domain } . " ) )
return f " { label } — { domain } " , description . replace ( " le domaine " , f " le domaine « { domain } » " )
2026-08-14 20:53:03 +02:00
def canonical_role ( role ) :
return ROLE_ALIASES . get ( ( role or " " ) . strip ( ) . lower ( ) , " lecture " )
def has_permission ( permission , user = None ) :
2026-08-21 18:46:19 +02:00
""" Retourne le droit effectif, en mode fail-closed.
La matrice SQL est la seule source de vérité à l ' exécution : tous les
rôles actifs sont réunis , puis les exceptions individuelles sont appliquées
avec priorité aux refus . Aucune valeur de ` ` User . role ` ` n ' est consultée.
"""
2026-08-14 20:53:03 +02:00
user = user or current_user
if not getattr ( user , " is_authenticated " , False ) :
return False
2026-08-21 17:22:47 +02:00
try :
2026-08-21 18:46:19 +02:00
from . models . rbac import RolePermission
2026-08-21 17:22:47 +02:00
from datetime import datetime , timezone
roles = [ link . role for link in user . role_links if link . role and link . role . is_active ]
2026-08-21 20:10:29 +02:00
if any ( role . slug == " super_admin " for role in roles ) :
return True
2026-08-21 17:22:47 +02:00
if roles :
2026-08-21 18:46:19 +02:00
granted = {
link . permission . code
for role in roles
for link in role . permission_links
if link . effect == " allow " and link . permission and link . permission . is_active
}
else :
granted = set ( )
now = datetime . now ( timezone . utc )
allowed_overrides = set ( )
denied = set ( )
for override in user . permission_links :
expiry = override . expires_at
if expiry and expiry . tzinfo is None :
expiry = expiry . replace ( tzinfo = timezone . utc )
if expiry and expiry < now :
continue
if not override . permission or not override . permission . is_active :
continue
if override . effect == " deny " :
denied . add ( override . permission . code )
elif override . effect == " allow " :
allowed_overrides . add ( override . permission . code )
granted . update ( allowed_overrides )
if permission in denied :
return False
if permission in granted or " * " in granted :
return True
module = permission . rsplit ( " . " , 1 ) [ 0 ] if " . " in permission else permission
return f " { module } .manage " in granted and f " { module } .manage " not in denied
2026-08-21 17:22:47 +02:00
except Exception :
2026-08-21 18:46:19 +02:00
current_app . logger . exception ( " Erreur RBAC lors du calcul de %s pour l ' utilisateur %s " , permission , getattr ( user , " id " , None ) )
return False
2026-08-14 20:53:03 +02:00
def permission_required ( permission ) :
def decorator ( view ) :
@wraps ( view )
def wrapped ( * args , * * kwargs ) :
if not has_permission ( permission ) :
abort ( 403 )
return view ( * args , * * kwargs )
return wrapped
return decorator
def required_permission ( endpoint , method ) :
""" Déduit la permission minimale d ' une route enregistrée. """
if not endpoint :
return None
blueprint = endpoint . split ( " . " , 1 ) [ 0 ]
mutating = method not in { " GET " , " HEAD " , " OPTIONS " }
2026-08-21 17:22:47 +02:00
if blueprint == " cleaning " :
2026-08-21 20:10:29 +02:00
explicit = {
" cleaning.receive " : " stock.receive " ,
" cleaning.issue " : " stock.issue " ,
" cleaning.transfer " : " stock.transfer " ,
" cleaning.transvasement_page " : " stock.transvasement " ,
" cleaning.inventory " : " stock.inventory " ,
" cleaning.forecast_config " : " stock.configure " ,
" cleaning.locations " : " stock.configure " ,
" cleaning.material_assign " : " patrimoine.edit " ,
" cleaning.product_new " : " stock.configure " ,
" cleaning.product_edit " : " stock.configure " ,
" cleaning.reference_new " : " stock.configure " ,
" cleaning.packaging_new " : " stock.configure " ,
}
if endpoint in explicit :
return explicit [ endpoint ]
2026-08-21 17:22:47 +02:00
action = " view "
if mutating :
action = " create " if any ( token in endpoint for token in ( " new " , " receive " , " issue " , " transfer " , " transvasement " , " inventory " ) ) else " edit "
if " delete " in endpoint :
action = " delete "
if " validate " in endpoint :
action = " validate "
return f " stock. { action } "
2026-08-21 17:48:59 +02:00
if blueprint == " gmao_config " :
return " gmao_config.configure " if mutating else " gmao_config.view "
if blueprint == " yeastar " :
return " watchdog_dnd.configure " if " dnd " in endpoint and mutating else ( " watchdog_dnd.view " if " dnd " in endpoint else " integration.yeastar.configure " if mutating else " integration.yeastar.view " )
if blueprint == " logs " :
return " watchdog_dnd.view " if " watchdog " in endpoint or endpoint == " logs.index " else " audit.view "
if blueprint in { " ent " , " outlook_pages " , " outlook_dashboard " , " outlook_sync " } :
integration = " ent " if blueprint == " ent " else " outlook "
return f " integration. { integration } .configure " if mutating else f " integration. { integration } .view "
if blueprint == " pronote " :
return " integration.pronote.configure " if mutating else " integration.pronote.view "
if blueprint == " admin " :
if endpoint . startswith ( " admin.user " ) or endpoint in { " admin.list_users " , " admin.users " } :
return " user.manage " if mutating else " user.view "
if endpoint . startswith ( " admin.role " ) or endpoint == " admin.permissions " :
return " role.manage " if mutating else " role.view "
if endpoint == " admin.audit_logs " :
return " audit.view "
if endpoint . startswith ( " admin.settings " ) :
return " system.configure " if mutating else " system.view "
2026-08-14 20:53:03 +02:00
if blueprint in ADMIN_BLUEPRINTS :
return " system.admin "
if blueprint == " auth " :
return " system.admin " if endpoint not in { " auth.profile " , " auth.change_own_password " , " auth.logout " } else None
if blueprint == " setup_wizard " :
return " system.admin "
if blueprint in PATRIMOINE_BLUEPRINTS :
2026-08-21 20:10:29 +02:00
return " patrimoine.edit " if mutating else " patrimoine.view "
2026-08-14 20:53:03 +02:00
if blueprint in PLANNING_BLUEPRINTS :
2026-08-21 20:10:29 +02:00
return " planning.edit " if mutating else " planning.view "
2026-08-14 20:53:03 +02:00
if blueprint == " interventions " :
2026-08-21 20:10:29 +02:00
explicit = {
" interventions.change_status " : " intervention.change_status " ,
" interventions.refuse " : " intervention.reject " ,
" interventions.soft_delete " : " intervention.delete " ,
" interventions.postpone " : " intervention.postpone " ,
" interventions.add_comment " : " intervention.comment " ,
" interventions.save_work_details " : " intervention.edit " ,
" interventions.update_location " : " intervention.edit " ,
}
if endpoint in explicit :
return explicit [ endpoint ]
2026-08-14 20:53:03 +02:00
if endpoint in { " interventions.create " , " interventions.create_for_group " } :
return " intervention.create "
2026-08-21 20:10:29 +02:00
return " intervention.edit " if mutating else " intervention.view "
2026-08-15 00:56:36 +02:00
if blueprint == " documents " :
if " intervention " in endpoint :
return " intervention.manage " if mutating else " intervention.view "
if " equipment " in endpoint :
return " patrimoine.manage " if mutating else " patrimoine.view "
return " system.admin "
2026-08-14 20:53:03 +02:00
if blueprint in STOCK_BLUEPRINTS :
2026-08-21 20:10:29 +02:00
return " stock.edit " if mutating else " stock.view "
2026-08-14 20:53:03 +02:00
if blueprint in CONTRACT_BLUEPRINTS :
return " contract.manage " if mutating else " contract.view "
if blueprint in PREVENTION_BLUEPRINTS :
return " prevention.manage " if mutating else " prevention.view "
if blueprint == " exports " :
return " export.use "
if blueprint in { " dashboard " , " notifications " , " messagerie " , " chatbot " } :
return " dashboard.view "
return " system.admin " if mutating else " dashboard.view "
def enforce_route_permission ( ) :
""" Garde global appelé avant chaque requête. """
endpoint = request . endpoint
if endpoint in PUBLIC_ENDPOINTS or endpoint == " static " :
return None
# L'API REST applique sa clé ou sa session dans son propre décorateur.
if endpoint and endpoint . startswith ( " api_v1. " ) :
return None
if not current_user . is_authenticated :
2026-08-21 18:46:19 +02:00
if request . path . startswith ( ' /api/ ' ) or request . is_json or request . accept_mimetypes . best == ' application/json ' :
return jsonify ( { ' error ' : ' Authentification requise ' } ) , 401
return redirect ( url_for ( ' auth.login ' , next = request . full_path . rstrip ( ' ? ' ) ) )
2026-08-14 20:53:03 +02:00
permission = required_permission ( endpoint , request . method )
if permission and not has_permission ( permission ) :
abort ( 403 )
return None