gmao/app_new/core/authorization.py
root 785c8aabcb
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Refondre le RBAC multi-roles et securiser les acces
2026-08-21 16:46:19 +00:00

267 lines
11 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 = {
"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 = {
"admin": "Administrateur système",
"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 = {
"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 affiché dans la matrice RBAC. Les anciennes permissions
# ``*.manage`` restent compatibles et impliquent les actions ci-dessous.
GRANULAR_PERMISSION_CODES = {
f"{module}.{action}"
for module in ("dashboard", "patrimoine", "intervention", "planning", "stock", "contract", "prevention", "user", "system")
for action in ("view", "create", "edit", "delete", "validate", "reject", "export", "configure", "manage")
}
GRANULAR_PERMISSION_CODES |= {
"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",
}
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 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":
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.manage" if mutating else "patrimoine.view"
if blueprint in PLANNING_BLUEPRINTS:
return "planning.manage" if mutating else "planning.view"
if blueprint == "interventions":
if endpoint in {"interventions.create", "interventions.create_for_group"}:
return "intervention.create"
return "intervention.manage" 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.manage" 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