From d3ddfdb974366d8c3ad763c53a92a201b5e3401b Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 18:53:03 +0000 Subject: [PATCH] =?UTF-8?q?Centralise=20les=20r=C3=B4les=20et=20les=20perm?= =?UTF-8?q?issions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/__init__.py | 4 + app_new/constants.py | 9 +- app_new/core/authorization.py | 150 ++++++++++++++ app_new/core/forms.py | 7 +- app_new/core/models/user.py | 21 +- app_new/core/routes/admin.py | 61 +++--- app_new/core/routes/auth.py | 10 +- app_new/templates/admin/permissions.html | 194 +++++------------- app_new/templates/admin/user_form.html | 8 +- app_new/templates/base.html | 2 +- .../a2b6c7d8e9f0_normalize_user_roles.py | 38 ++++ tests/factories/user.py | 2 +- tests/integration/test_authorization.py | 61 ++++++ 13 files changed, 367 insertions(+), 200 deletions(-) create mode 100644 app_new/core/authorization.py create mode 100644 migrations/versions/a2b6c7d8e9f0_normalize_user_roles.py create mode 100644 tests/integration/test_authorization.py diff --git a/app_new/__init__.py b/app_new/__init__.py index a6c211c..500e76c 100644 --- a/app_new/__init__.py +++ b/app_new/__init__.py @@ -54,6 +54,10 @@ def create_app(config_name='default'): login_manager.init_app(app) migrate.init_app(app, db) csrf.init_app(app) + + from .core.authorization import enforce_route_permission, has_permission + app.before_request(enforce_route_permission) + app.jinja_env.globals["has_permission"] = has_permission # Configuration du login manager login_manager.login_view = 'auth.login' diff --git a/app_new/constants.py b/app_new/constants.py index 27f1fb8..27e3b17 100644 --- a/app_new/constants.py +++ b/app_new/constants.py @@ -77,7 +77,10 @@ WORK_TYPES = { # Rôles utilisateurs ROLES = { - "tech": "Technicien", - "chef": "Chef de service", "admin": "Administrateur", -} \ No newline at end of file + "responsable_gmao": "Responsable GMAO", + "technicien": "Technicien", + "assistant_prevention": "Assistant de prévention", + "demandeur": "Demandeur", + "lecture": "Lecture seule", +} diff --git a/app_new/core/authorization.py b/app_new/core/authorization.py new file mode 100644 index 0000000..f2a650c --- /dev/null +++ b/app_new/core/authorization.py @@ -0,0 +1,150 @@ +"""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", + }, + "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", + "equipments_scheduled", "buildings", "zones", "rooms", "room_types", "wizard", "lots", +} +PLANNING_BLUEPRINTS = {"planning", "scheduler", "interventions_planning"} +STOCK_BLUEPRINTS = {"parts", "meters"} +CONTRACT_BLUEPRINTS = {"companies", "contracts", "services"} +PREVENTION_BLUEPRINTS = {"trainings", "constraints"} + + +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 + permissions = PERMISSIONS_BY_ROLE.get(canonical_role(user.role), set()) + return "*" in permissions or permission 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 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 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 diff --git a/app_new/core/forms.py b/app_new/core/forms.py index 54fafee..025b654 100644 --- a/app_new/core/forms.py +++ b/app_new/core/forms.py @@ -32,9 +32,12 @@ class UserForm(FlaskForm): full_name = StringField("Nom complet", validators=[DataRequired()]) password = PasswordField("Mot de passe", validators=[Optional(), Length(min=12)]) role = SelectField("Rôle", choices=[ - ("tech", "Technicien"), - ("chef", "Chef de service"), ("admin", "Administrateur"), + ("responsable_gmao", "Responsable GMAO"), + ("technicien", "Technicien"), + ("assistant_prevention", "Assistant de prévention"), + ("demandeur", "Demandeur"), + ("lecture", "Lecture seule"), ]) is_active = BooleanField("Actif") diff --git a/app_new/core/models/user.py b/app_new/core/models/user.py index d0f6490..ff0b937 100644 --- a/app_new/core/models/user.py +++ b/app_new/core/models/user.py @@ -18,7 +18,7 @@ class User(UserMixin, db.Model): email = db.Column(db.String(120), unique=True, nullable=True) password_hash = db.Column(db.String(256), nullable=False) full_name = db.Column(db.String(150), nullable=False, default="") - role = db.Column(db.String(20), default="user") # admin, user, viewer + role = db.Column(db.String(32), default="demandeur", nullable=False) is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) last_login = db.Column(db.DateTime, nullable=True) @@ -33,7 +33,22 @@ class User(UserMixin, db.Model): def is_admin(self): """Vérifie si l'utilisateur est admin.""" - return self.role == "admin" + from ..authorization import canonical_role + return canonical_role(self.role) == "admin" + + @property + def canonical_role(self): + from ..authorization import canonical_role + return canonical_role(self.role) + + @property + def role_label(self): + from ..authorization import ROLE_LABELS + return ROLE_LABELS.get(self.canonical_role, self.role or "—") + + def has_permission(self, permission): + from ..authorization import has_permission + return has_permission(permission, self) def __repr__(self): return f"" @@ -59,4 +74,4 @@ class Staff(db.Model): return f"{self.first_name} {self.last_name}" def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/app_new/core/routes/admin.py b/app_new/core/routes/admin.py index 2ed6a2d..e73a0de 100644 --- a/app_new/core/routes/admin.py +++ b/app_new/core/routes/admin.py @@ -8,7 +8,8 @@ from ...extensions import db from ..models.user import User, Staff from ..models.college import College, Building, Room, RoomType from ..models.company import Company, Service -from app_new.constants import INTERVENTION_STATUSES, EQUIPMENT_STATUSES +from app_new.constants import INTERVENTION_STATUSES, EQUIPMENT_STATUSES, ROLES +from ..authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS, canonical_role admin_bp = Blueprint('admin', __name__) @@ -62,7 +63,7 @@ def user_new(): email = (request.form.get('email') or '').strip() password = request.form.get('password') or '' password_confirm = request.form.get('password_confirm') or '' - role = request.form.get('role', 'user') + role = request.form.get('role', 'demandeur') if len(username) < 3: flash('Le nom d’utilisateur doit contenir au moins 3 caractères.', 'danger') @@ -86,7 +87,7 @@ def user_new(): flash('Utilisateur créé avec succès.', 'success') return redirect(url_for('admin.users')) - return render_template('admin/user_form.html') + return render_template('admin/user_form.html', roles=ROLES) @admin_bp.route('/users//edit', methods=['GET', 'POST']) @@ -97,8 +98,12 @@ def user_edit(user_id): user = User.query.get_or_404(user_id) if request.method == 'POST': + role = request.form.get('role') + if role not in ROLES: + flash('Rôle utilisateur invalide.', 'danger') + return render_template('admin/user_form.html', user=user, roles=ROLES), 400 user.email = request.form.get('email') - user.role = request.form.get('role') + user.role = role user.is_active = request.form.get('is_active') == 'on' new_password = request.form.get('new_password') @@ -112,7 +117,7 @@ def user_edit(user_id): flash('Utilisateur modifié avec succès.', 'success') return redirect(url_for('admin.users')) - return render_template('admin/user_form.html', user=user) + return render_template('admin/user_form.html', user=user, roles=ROLES) @admin_bp.route('/permissions') @@ -121,43 +126,29 @@ def user_edit(user_id): def permissions(): """Liste des permissions.""" from ..models.user import User - from ...constants import ROLES - - # Compter les utilisateurs par rôle - roles_permissions = {} - for role_key, role_name in ROLES.items(): - roles_permissions[role_key] = User.query.filter_by(role=role_key).all() - - # Permissions disponibles (simplifié) - all_permissions = [ - 'dashboard:view', 'interventions:view', 'interventions:create', 'interventions:edit', - 'equipments:view', 'equipments:create', 'equipments:edit', - 'planning:view', 'planning:create', - 'admin:access', 'users:manage' - ] - - return render_template('admin/permissions.html', - roles_permissions=roles_permissions, - all_permissions=all_permissions) - - -@admin_bp.route('/permissions', methods=['POST']) -@login_required -@admin_required -def update_permissions(): - """Mettre à jour les permissions.""" - flash('Permissions mises à jour.', 'success') - return redirect(url_for('admin.permissions')) + users_by_role = {role: [] for role in ROLES} + for user in User.query.order_by(User.full_name).all(): + users_by_role[canonical_role(user.role)].append(user) + return render_template( + 'admin/permissions.html', roles=ROLES, role_labels=ROLE_LABELS, + users_by_role=users_by_role, permissions_by_role=PERMISSIONS_BY_ROLE, + ) @admin_bp.route('/users//permissions', methods=['POST']) @login_required @admin_required def update_user_permissions(id): - """Mettre à jour les permissions d'un utilisateur.""" + """Change le rôle d'un utilisateur (les permissions sont définies en code).""" user = User.query.get_or_404(id) - flash(f'Permissions de {user.username} mises à jour.', 'success') - return redirect(url_for('admin.user_permissions', id=id)) + role = request.form.get('role') + if role not in ROLES: + flash('Rôle invalide.', 'danger') + return redirect(url_for('admin.permissions')) + user.role = role + db.session.commit() + flash(f'Rôle de {user.username} mis à jour.', 'success') + return redirect(url_for('admin.permissions')) @admin_bp.route('/users/list') diff --git a/app_new/core/routes/auth.py b/app_new/core/routes/auth.py index 7f48e62..8e4884d 100644 --- a/app_new/core/routes/auth.py +++ b/app_new/core/routes/auth.py @@ -49,7 +49,7 @@ def logout(): @login_required def list_users(): """Liste des utilisateurs.""" - if current_user.role != 'admin': + if not current_user.is_admin(): flash("Accès réservé aux administrateurs.", "danger") return redirect(url_for('dashboard.index')) @@ -61,7 +61,7 @@ def list_users(): @login_required def create_user(): """Créer un utilisateur.""" - if current_user.role != 'admin': + if not current_user.is_admin(): flash("Accès réservé aux administrateurs.", "danger") return redirect(url_for('dashboard.index')) @@ -98,7 +98,7 @@ def profile(): @login_required def edit_user(id): """Modifier un utilisateur.""" - if current_user.role != 'admin': + if not current_user.is_admin(): flash("Accès réservé aux administrateurs.", "danger") return redirect(url_for('dashboard.index')) @@ -144,7 +144,7 @@ def change_own_password(): @login_required def delete_user(id): """Supprimer un utilisateur.""" - if current_user.role != 'admin': + if not current_user.is_admin(): flash("Accès réservé aux administrateurs.", "danger") return redirect(url_for('dashboard.index')) @@ -163,7 +163,7 @@ def delete_user(id): @login_required def change_user_password(id): """Changer le mot de passe d'un utilisateur (admin only).""" - if current_user.role != 'admin': + if not current_user.is_admin(): flash("Accès réservé aux administrateurs.", "danger") return redirect(url_for('dashboard.index')) diff --git a/app_new/templates/admin/permissions.html b/app_new/templates/admin/permissions.html index a209de4..bf21715 100644 --- a/app_new/templates/admin/permissions.html +++ b/app_new/templates/admin/permissions.html @@ -1,161 +1,63 @@ {% extends "base.html" %} -{% block title %}Permissions - GMAO Collège{% endblock %} +{% block title %}Rôles et permissions — GMAO{% endblock %} {% block content %}
-
-
-

Gestion des permissions

-

Gérer les accès par rôle et utilisateur

-
- +
+
+

Rôles et permissions

+

La matrice est centralisée dans l'application. Attribuez ici un rôle à chaque compte.

+ Utilisateurs +
- -
-
-
-
- -
Administrateurs
- {{ roles_permissions.admin|length }} -
-
-
-
-
-
- -
Techniciens
- {{ roles_permissions.technicien|length }} -
-
-
-
-
-
- -
Chefs
- {{ roles_permissions.chef|length }} -
-
-
-
-
-
- -
Demandeurs
- {{ roles_permissions.demandeur|length }} -
-
-
-
+
+ Les permissions ne sont pas modifiables case par case : cela évite les droits invisibles et les écarts entre écrans. + Toute évolution de la matrice est versionnée et testée. +
- -
-
-
Permissions par rôle
+
+ {% for role_key, role_name in roles.items() %} +
+
+
+ {{ role_name }} + {{ users_by_role[role_key]|length }} compte(s)
-
-
- {% for role, permissions in roles_permissions.items() %} -
-
-
{{ role|title }}
-
- {% for perm in all_permissions %} -
-
- - -
-
- {% endfor %} -
-
-
- {% endfor %} -
-
- -
-
-
-
+

Permissions

+ {% if '*' in permissions_by_role[role_key] %} + Accès complet + {% else %} +
+ {% for permission in permissions_by_role[role_key]|sort %} + {{ permission }} + {% endfor %} +
+ {% endif %} - -
-
-
Légende des permissions
-
-
-
-
-
Dashboard
-
    -
  • dashboard:view - Voir le tableau de bord
  • -
-
-
-
Interventions
-
    -
  • code:CREATE_INTERVENTION - Créer
  • -
  • code:VIEW_INTERVENTIONS - Voir
  • -
  • code:EDIT_INTERVENTIONS - Modifier
  • -
  • code:DELETE_INTERVENTIONS - Supprimer
  • -
-
-
-
Équipements
-
    -
  • code:VIEW_EQUIPMENTS - Voir
  • -
  • code:CREATE_EQUIPMENTS - Créer
  • -
  • code:EDIT_EQUIPMENTS - Modifier
  • -
  • code:DELETE_EQUIPMENTS - Supprimer
  • -
-
-
-
Planning
-
    -
  • code:VIEW_PLANNING - Voir
  • -
  • code:CREATE_PLANNING - Créer
  • -
  • code:EDIT_PLANNING - Modifier
  • -
-
-
-
ENT77
-
    -
  • code:VIEW_ENT - Voir les messages
  • -
  • code:CONFIG_ENT - Configurer
  • -
  • code:MANAGE_ENT_MESSAGES - Gérer
  • -
-
-
-
Administration
-
    -
  • code:VIEW_USERS - Voir utilisateurs
  • -
  • code:CREATE_USERS - Créer
  • -
  • code:EDIT_USERS - Modifier
  • -
  • code:DELETE_USERS - Supprimer
  • -
  • code:VIEW_ADMIN - Voir admin
  • -
-
+ {% if users_by_role[role_key] %} +
+ {% for user in users_by_role[role_key] %} +
+ +
{{ user.full_name or user.username }}
+
+
+
+
+ {% endfor %} + {% endif %}
+
+ {% endfor %} +
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/admin/user_form.html b/app_new/templates/admin/user_form.html index f27e637..189ad56 100644 --- a/app_new/templates/admin/user_form.html +++ b/app_new/templates/admin/user_form.html @@ -18,7 +18,7 @@
- {{ csrf_token() }} +
@@ -41,9 +41,9 @@
diff --git a/app_new/templates/base.html b/app_new/templates/base.html index 7306a6c..7b74575 100644 --- a/app_new/templates/base.html +++ b/app_new/templates/base.html @@ -223,7 +223,7 @@ - {% if current_user.role == 'admin' %} + {% if current_user.is_admin() %}