205 lines
8.5 KiB
Python
205 lines
8.5 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
|
|
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",
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
|
|
def canonical_role(role):
|
|
return ROLE_ALIASES.get((role or "").strip().lower(), "lecture")
|
|
|
|
|
|
def has_permission(permission, user=None):
|
|
user = user or current_user
|
|
if not getattr(user, "is_authenticated", False):
|
|
return False
|
|
# Les rôles configurables sont prioritaires lorsqu'ils existent. Le
|
|
# fallback statique conserve la compatibilité avec les anciennes bases
|
|
# avant application de la migration RBAC.
|
|
try:
|
|
from .models.rbac import Role, RolePermission, Permission, UserPermission
|
|
from datetime import datetime, timezone
|
|
roles = [link.role for link in user.role_links if link.role and link.role.is_active]
|
|
legacy_role = Role.query.filter_by(slug=user.role, is_active=True).first()
|
|
if legacy_role and legacy_role not in roles:
|
|
roles.append(legacy_role)
|
|
if roles:
|
|
granted = {link.permission.code for role in roles for link in role.permission_links
|
|
if link.effect == "allow" and link.permission.is_active}
|
|
denied = set()
|
|
for override in user.permission_links:
|
|
if override.expires_at and override.expires_at < datetime.now(timezone.utc):
|
|
continue
|
|
(granted if override.effect == "allow" else denied).add(override.permission.code)
|
|
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:
|
|
# Une base non migrée ne doit pas rendre toutes les routes indisponibles.
|
|
pass
|
|
permissions = PERMISSIONS_BY_ROLE.get(canonical_role(user.role), set())
|
|
if "*" in permissions or permission in permissions:
|
|
return True
|
|
module = permission.rsplit(".", 1)[0] if "." in permission else permission
|
|
return f"{module}.manage" in permissions
|
|
|
|
|
|
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 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:
|
|
return None # Flask-Login conserve la redirection habituelle des vues protégées.
|
|
permission = required_permission(endpoint, request.method)
|
|
if permission and not has_permission(permission):
|
|
abort(403)
|
|
return None
|