351 lines
17 KiB
Python
351 lines
17 KiB
Python
"""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
|
|
|
|
from flask import abort, request, redirect, url_for, jsonify, current_app
|
|
from flask_login import current_user
|
|
|
|
|
|
ROLE_ALIASES = {
|
|
# Alias historique conservé uniquement pour l'affichage/compatibilité.
|
|
# La sécurité utilise le slug RBAC réel ``super_admin``.
|
|
"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 = {
|
|
"super_admin": "Super administrateur",
|
|
"admin": "Administrateur",
|
|
"responsable_gmao": "Responsable GMAO",
|
|
"technicien": "Technicien",
|
|
"assistant_prevention": "Assistant de prévention",
|
|
"demandeur": "Demandeur",
|
|
"lecture": "Lecture seule",
|
|
}
|
|
|
|
# LEGACY_SEED_ONLY : utilisé uniquement par les migrations historiques et le
|
|
# bootstrap des tests. Le runtime n'en fait jamais un fallback d'autorisation.
|
|
PERMISSIONS_BY_ROLE = {
|
|
# 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",
|
|
},
|
|
"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",
|
|
"housing.private",
|
|
},
|
|
"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 = {
|
|
"equipments", "equipments_meters", "equipments_documents", "equipments_restrictions", "housing",
|
|
"equipments_scheduled", "buildings", "zones", "rooms", "room_types", "wizard", "lots",
|
|
}
|
|
PLANNING_BLUEPRINTS = {"planning", "scheduler", "interventions_planning"}
|
|
STOCK_BLUEPRINTS = {"parts", "meters", "cleaning"}
|
|
CONTRACT_BLUEPRINTS = {"companies", "contracts", "services"}
|
|
PREVENTION_BLUEPRINTS = {"trainings", "constraints", "prevention"}
|
|
|
|
# Catalogue réellement utilisé par les routes et les fonctions métier.
|
|
GRANULAR_PERMISSION_CODES = {
|
|
code for codes in PERMISSIONS_BY_ROLE.values() for code in codes if code != "*"
|
|
} | {
|
|
"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",
|
|
}
|
|
|
|
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} »")
|
|
|
|
|
|
def canonical_role(role):
|
|
return ROLE_ALIASES.get((role or "").strip().lower(), "lecture")
|
|
|
|
|
|
def has_permission(permission, user=None):
|
|
"""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.
|
|
"""
|
|
user = user or current_user
|
|
if not getattr(user, "is_authenticated", False):
|
|
return False
|
|
try:
|
|
from .models.rbac import RolePermission
|
|
from datetime import datetime, timezone
|
|
roles = [link.role for link in user.role_links if link.role and link.role.is_active]
|
|
if any(role.slug == "super_admin" for role in roles):
|
|
return True
|
|
if roles:
|
|
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
|
|
except Exception:
|
|
current_app.logger.exception("Erreur RBAC lors du calcul de %s pour l'utilisateur %s", permission, getattr(user, "id", None))
|
|
return False
|
|
|
|
|
|
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"}
|
|
|
|
if blueprint == "cleaning":
|
|
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]
|
|
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}"
|
|
|
|
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"
|
|
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:
|
|
return "patrimoine.edit" if mutating else "patrimoine.view"
|
|
if blueprint in PLANNING_BLUEPRINTS:
|
|
return "planning.edit" if mutating else "planning.view"
|
|
if blueprint == "interventions":
|
|
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]
|
|
if endpoint in {"interventions.create", "interventions.create_for_group"}:
|
|
return "intervention.create"
|
|
return "intervention.edit" if mutating else "intervention.view"
|
|
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"
|
|
if blueprint in STOCK_BLUEPRINTS:
|
|
return "stock.edit" if mutating else "stock.view"
|
|
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:
|
|
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('?')))
|
|
permission = required_permission(endpoint, request.method)
|
|
if permission and not has_permission(permission):
|
|
abort(403)
|
|
return None
|