267 lines
11 KiB
Python
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
|