"""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